feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,310 @@
// Package applicationstore implements the PostgreSQL-backed adapter for
// `ports.ApplicationStore`.
//
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
// application records; see `galaxy/lobby/docs/postgres-migration.md` for
// the full decision record.
package applicationstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed application store instance.
type Config struct {
// DB stores the connection pool the store uses for every query.
DB *sql.DB
// OperationTimeout bounds one round trip.
OperationTimeout time.Duration
}
// Store persists Game Lobby application records in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed application store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres application store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres application store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// applicationSelectColumns is the canonical SELECT list for the applications
// table, matching scanApplication's column order.
var applicationSelectColumns = pg.ColumnList{
pgtable.Applications.ApplicationID,
pgtable.Applications.GameID,
pgtable.Applications.ApplicantUserID,
pgtable.Applications.RaceName,
pgtable.Applications.Status,
pgtable.Applications.CreatedAt,
pgtable.Applications.DecidedAt,
}
// Save persists a new submitted application record. The single-active
// constraint is enforced by the partial unique index
// `applications_active_per_user_game_uidx`.
func (store *Store) Save(ctx context.Context, record application.Application) error {
if store == nil || store.db == nil {
return errors.New("save application: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("save application: %w", err)
}
if record.Status != application.StatusSubmitted {
return fmt.Errorf(
"save application: status must be %q, got %q",
application.StatusSubmitted, record.Status,
)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save application", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.Applications.INSERT(
pgtable.Applications.ApplicationID,
pgtable.Applications.GameID,
pgtable.Applications.ApplicantUserID,
pgtable.Applications.RaceName,
pgtable.Applications.Status,
pgtable.Applications.CreatedAt,
pgtable.Applications.DecidedAt,
).VALUES(
record.ApplicationID.String(),
record.GameID.String(),
record.ApplicantUserID,
record.RaceName,
string(record.Status),
record.CreatedAt.UTC(),
sqlx.NullableTimePtr(record.DecidedAt),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("save application: %w", application.ErrConflict)
}
return fmt.Errorf("save application: %w", err)
}
return nil
}
// Get returns the record identified by applicationID. It returns
// application.ErrNotFound when no record exists.
func (store *Store) Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error) {
if store == nil || store.db == nil {
return application.Application{}, errors.New("get application: nil store")
}
if err := applicationID.Validate(); err != nil {
return application.Application{}, fmt.Errorf("get application: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get application", store.operationTimeout)
if err != nil {
return application.Application{}, err
}
defer cancel()
stmt := pg.SELECT(applicationSelectColumns).
FROM(pgtable.Applications).
WHERE(pgtable.Applications.ApplicationID.EQ(pg.String(applicationID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanApplication(row)
if sqlx.IsNoRows(err) {
return application.Application{}, application.ErrNotFound
}
if err != nil {
return application.Application{}, fmt.Errorf("get application: %w", err)
}
return record, nil
}
// GetByGame returns every application attached to gameID. Sorted by
// created_at ASC then application_id ASC.
func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error) {
if store == nil || store.db == nil {
return nil, errors.New("get applications by game: nil store")
}
if err := gameID.Validate(); err != nil {
return nil, fmt.Errorf("get applications by game: %w", err)
}
stmt := pg.SELECT(applicationSelectColumns).
FROM(pgtable.Applications).
WHERE(pgtable.Applications.GameID.EQ(pg.String(gameID.String()))).
ORDER_BY(pgtable.Applications.CreatedAt.ASC(), pgtable.Applications.ApplicationID.ASC())
return store.queryList(ctx, "get applications by game", stmt)
}
// GetByUser returns every application submitted by applicantUserID.
func (store *Store) GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error) {
if store == nil || store.db == nil {
return nil, errors.New("get applications by user: nil store")
}
trimmed := strings.TrimSpace(applicantUserID)
if trimmed == "" {
return nil, fmt.Errorf("get applications by user: applicant user id must not be empty")
}
stmt := pg.SELECT(applicationSelectColumns).
FROM(pgtable.Applications).
WHERE(pgtable.Applications.ApplicantUserID.EQ(pg.String(trimmed))).
ORDER_BY(pgtable.Applications.CreatedAt.ASC(), pgtable.Applications.ApplicationID.ASC())
return store.queryList(ctx, "get applications by user", stmt)
}
func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]application.Application, error) {
operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
defer rows.Close()
records := make([]application.Application, 0)
for rows.Next() {
record, err := scanApplication(rows)
if err != nil {
return nil, fmt.Errorf("%s: scan: %w", operation, err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
if len(records) == 0 {
return nil, nil
}
return records, nil
}
// UpdateStatus applies one status transition with compare-and-swap on the
// current status column.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateApplicationStatusInput) error {
if store == nil || store.db == nil {
return errors.New("update application status: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update application status: %w", err)
}
if err := application.Transition(input.ExpectedFrom, input.To); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update application status", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
at := input.At.UTC()
stmt := pgtable.Applications.UPDATE(pgtable.Applications.Status, pgtable.Applications.DecidedAt).
SET(string(input.To), at).
WHERE(pg.AND(
pgtable.Applications.ApplicationID.EQ(pg.String(input.ApplicationID.String())),
pgtable.Applications.Status.EQ(pg.String(string(input.ExpectedFrom))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update application status: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update application status: rows affected: %w", err)
}
if affected == 0 {
probe := pg.SELECT(pgtable.Applications.Status).
FROM(pgtable.Applications).
WHERE(pgtable.Applications.ApplicationID.EQ(pg.String(input.ApplicationID.String())))
probeQuery, probeArgs := probe.Sql()
var current string
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
if err := row.Scan(&current); err != nil {
if sqlx.IsNoRows(err) {
return application.ErrNotFound
}
return fmt.Errorf("update application status: probe: %w", err)
}
return fmt.Errorf("update application status: %w", application.ErrConflict)
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanApplication(rs rowScanner) (application.Application, error) {
var (
applicationID string
gameID string
applicantUserID string
raceName string
status string
createdAt time.Time
decidedAt sql.NullTime
)
if err := rs.Scan(
&applicationID,
&gameID,
&applicantUserID,
&raceName,
&status,
&createdAt,
&decidedAt,
); err != nil {
return application.Application{}, err
}
return application.Application{
ApplicationID: common.ApplicationID(applicationID),
GameID: common.GameID(gameID),
ApplicantUserID: applicantUserID,
RaceName: raceName,
Status: application.Status(status),
CreatedAt: createdAt.UTC(),
DecidedAt: sqlx.TimePtrFromNullable(decidedAt),
}, nil
}
// Ensure Store satisfies the ports.ApplicationStore interface at compile
// time.
var _ ports.ApplicationStore = (*Store)(nil)
@@ -0,0 +1,194 @@
package applicationstore_test
import (
"context"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/applicationstore"
"galaxy/lobby/internal/adapters/postgres/gamestore"
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStores(t *testing.T) (*gamestore.Store, *applicationstore.Store) {
t.Helper()
pgtest.TruncateAll(t)
gs, err := gamestore.New(gamestore.Config{
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
as, err := applicationstore.New(applicationstore.Config{
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return gs, as
}
func seedGame(t *testing.T, gs *gamestore.Store, id string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
g, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "Game " + id,
GameType: game.GameTypePublic,
MinPlayers: 2,
MaxPlayers: 8,
StartGapHours: 12,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.0.0",
Now: now,
})
require.NoError(t, err)
require.NoError(t, gs.Save(context.Background(), g))
return g
}
func newApplication(t *testing.T, id, gameID, userID string) application.Application {
t.Helper()
a, err := application.New(application.NewApplicationInput{
ApplicationID: common.ApplicationID(id),
GameID: common.GameID(gameID),
ApplicantUserID: userID,
RaceName: "Pilot " + id,
Now: time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
return a
}
func TestSaveAndGet(t *testing.T) {
ctx := context.Background()
gs, as := newStores(t)
seedGame(t, gs, "game-001")
rec := newApplication(t, "application-001", "game-001", "user-a")
require.NoError(t, as.Save(ctx, rec))
got, err := as.Get(ctx, rec.ApplicationID)
require.NoError(t, err)
assert.Equal(t, rec.ApplicationID, got.ApplicationID)
assert.Equal(t, application.StatusSubmitted, got.Status)
assert.Equal(t, "user-a", got.ApplicantUserID)
assert.Nil(t, got.DecidedAt)
}
func TestSaveRejectsNonSubmittedRecord(t *testing.T) {
ctx := context.Background()
gs, as := newStores(t)
seedGame(t, gs, "game-001")
rec := newApplication(t, "application-001", "game-001", "user-a")
rec.Status = application.StatusApproved
require.Error(t, as.Save(ctx, rec))
}
func TestSavePartialUniqueRejectsSecondActiveForSameUserGame(t *testing.T) {
ctx := context.Background()
gs, as := newStores(t)
seedGame(t, gs, "game-001")
a1 := newApplication(t, "application-001", "game-001", "user-a")
require.NoError(t, as.Save(ctx, a1))
// second submission by the same user against the same game must fail.
a2 := newApplication(t, "application-002", "game-001", "user-a")
err := as.Save(ctx, a2)
require.ErrorIs(t, err, application.ErrConflict)
}
func TestSavePartialUniqueAllowsResubmitAfterRejection(t *testing.T) {
ctx := context.Background()
gs, as := newStores(t)
seedGame(t, gs, "game-001")
a1 := newApplication(t, "application-001", "game-001", "user-a")
require.NoError(t, as.Save(ctx, a1))
require.NoError(t, as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: a1.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusRejected,
At: a1.CreatedAt.Add(time.Minute),
}))
// after rejection a new submission for the same (user, game) is allowed.
a2 := newApplication(t, "application-002", "game-001", "user-a")
require.NoError(t, as.Save(ctx, a2))
}
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
ctx := context.Background()
gs, as := newStores(t)
seedGame(t, gs, "game-001")
rec := newApplication(t, "application-001", "game-001", "user-a")
require.NoError(t, as.Save(ctx, rec))
// First, transition the row to approved.
require.NoError(t, as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: rec.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusApproved,
At: rec.CreatedAt.Add(time.Minute),
}))
// Second attempt claims status is still submitted: (submitted, rejected)
// is a valid domain transition, but the row is already approved, so the
// adapter must surface ErrConflict on the row-level mismatch.
err := as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: rec.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusRejected,
At: rec.CreatedAt.Add(2 * time.Minute),
})
require.ErrorIs(t, err, application.ErrConflict)
}
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
_, as := newStores(t)
err := as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: common.ApplicationID("application-missing"),
ExpectedFrom: application.StatusSubmitted,
To: application.StatusApproved,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, application.ErrNotFound)
}
func TestGetByGameAndGetByUser(t *testing.T) {
ctx := context.Background()
gs, as := newStores(t)
seedGame(t, gs, "game-001")
seedGame(t, gs, "game-002")
require.NoError(t, as.Save(ctx, newApplication(t, "application-001", "game-001", "user-a")))
require.NoError(t, as.Save(ctx, newApplication(t, "application-002", "game-001", "user-b")))
require.NoError(t, as.Save(ctx, newApplication(t, "application-003", "game-002", "user-a")))
g1, err := as.GetByGame(ctx, common.GameID("game-001"))
require.NoError(t, err)
assert.Len(t, g1, 2)
userA, err := as.GetByUser(ctx, "user-a")
require.NoError(t, err)
assert.Len(t, userA, 2)
}
func TestGetMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
_, as := newStores(t)
_, err := as.Get(ctx, common.ApplicationID("application-missing"))
require.ErrorIs(t, err, application.ErrNotFound)
}
@@ -0,0 +1,94 @@
package gamestore
import (
"encoding/json"
"fmt"
"time"
"galaxy/lobby/internal/domain/game"
)
// runtimeSnapshotJSON is the on-disk JSONB shape used for the denormalised
// runtime snapshot column on `games`. Keys mirror the field names in
// `game.RuntimeSnapshot` so a round-trip remains naked-comparable.
type runtimeSnapshotJSON struct {
CurrentTurn int `json:"current_turn"`
RuntimeStatus string `json:"runtime_status,omitempty"`
EngineHealthSummary string `json:"engine_health_summary,omitempty"`
}
func marshalRuntimeSnapshot(snapshot game.RuntimeSnapshot) ([]byte, error) {
payload := runtimeSnapshotJSON{
CurrentTurn: snapshot.CurrentTurn,
RuntimeStatus: snapshot.RuntimeStatus,
EngineHealthSummary: snapshot.EngineHealthSummary,
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal runtime snapshot: %w", err)
}
return encoded, nil
}
func unmarshalRuntimeSnapshot(payload []byte) (game.RuntimeSnapshot, error) {
if len(payload) == 0 {
return game.RuntimeSnapshot{}, nil
}
var stored runtimeSnapshotJSON
if err := json.Unmarshal(payload, &stored); err != nil {
return game.RuntimeSnapshot{}, fmt.Errorf("unmarshal runtime snapshot: %w", err)
}
return game.RuntimeSnapshot{
CurrentTurn: stored.CurrentTurn,
RuntimeStatus: stored.RuntimeStatus,
EngineHealthSummary: stored.EngineHealthSummary,
}, nil
}
// runtimeBindingJSON is the on-disk JSONB shape used for the optional
// runtime binding column on `games`. The `bound_at_ms` field stores Unix
// milliseconds so the JSON serialisation matches the previous Redis JSON
// shape and the timezone is irrelevant inside the JSON payload itself; the
// adapter still re-wraps the resulting time.Time with .UTC() before exposing
// it to callers.
type runtimeBindingJSON struct {
ContainerID string `json:"container_id"`
EngineEndpoint string `json:"engine_endpoint"`
RuntimeJobID string `json:"runtime_job_id"`
BoundAtMS int64 `json:"bound_at_ms"`
}
// marshalRuntimeBinding returns nil bytes (SQL NULL) when binding is nil,
// otherwise the JSON encoding of the binding.
func marshalRuntimeBinding(binding *game.RuntimeBinding) ([]byte, error) {
if binding == nil {
return nil, nil
}
payload := runtimeBindingJSON{
ContainerID: binding.ContainerID,
EngineEndpoint: binding.EngineEndpoint,
RuntimeJobID: binding.RuntimeJobID,
BoundAtMS: binding.BoundAt.UTC().UnixMilli(),
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal runtime binding: %w", err)
}
return encoded, nil
}
func unmarshalRuntimeBinding(payload []byte) (*game.RuntimeBinding, error) {
if len(payload) == 0 {
return nil, nil
}
var stored runtimeBindingJSON
if err := json.Unmarshal(payload, &stored); err != nil {
return nil, fmt.Errorf("unmarshal runtime binding: %w", err)
}
return &game.RuntimeBinding{
ContainerID: stored.ContainerID,
EngineEndpoint: stored.EngineEndpoint,
RuntimeJobID: stored.RuntimeJobID,
BoundAt: time.UnixMilli(stored.BoundAtMS).UTC(),
}, nil
}
@@ -0,0 +1,610 @@
// Package gamestore implements the PostgreSQL-backed adapter for
// `ports.GameStore`.
//
// The package owns the on-disk shape of the `games` table (defined in
// `galaxy/lobby/internal/adapters/postgres/migrations`) and translates the
// schema-agnostic GameStore interface declared in `internal/ports` into
// concrete go-jet/v2 statements driven by the pgx driver. Per-row
// lifecycle transitions (Save/UpdateStatus/UpdateRuntimeSnapshot/
// UpdateRuntimeBinding) use optimistic concurrency on the `updated_at`
// column rather than retaining a `SELECT ... FOR UPDATE` lock across the
// caller's logic, mirroring the Notification Stage 5 pattern.
//
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
// game records; see `galaxy/lobby/docs/postgres-migration.md` for the full
// decision record.
package gamestore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed game store instance. The store
// does not own the underlying *sql.DB lifecycle: the caller (typically the
// service runtime) opens, instruments, migrates, and closes the pool.
type Config struct {
// DB stores the connection pool the store uses for every query.
DB *sql.DB
// OperationTimeout bounds one round trip. The store creates a derived
// context for each operation so callers cannot starve the pool with an
// unbounded ctx.
OperationTimeout time.Duration
}
// Store persists Game Lobby game records in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed game store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres game store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres game store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// gameSelectColumns is the canonical SELECT list for the games table,
// matching scanGame's column order.
var gameSelectColumns = pg.ColumnList{
pgtable.Games.GameID,
pgtable.Games.GameName,
pgtable.Games.Description,
pgtable.Games.GameType,
pgtable.Games.OwnerUserID,
pgtable.Games.Status,
pgtable.Games.MinPlayers,
pgtable.Games.MaxPlayers,
pgtable.Games.StartGapHours,
pgtable.Games.StartGapPlayers,
pgtable.Games.EnrollmentEndsAt,
pgtable.Games.TurnSchedule,
pgtable.Games.TargetEngineVersion,
pgtable.Games.CreatedAt,
pgtable.Games.UpdatedAt,
pgtable.Games.StartedAt,
pgtable.Games.FinishedAt,
pgtable.Games.RuntimeSnapshot,
pgtable.Games.RuntimeBinding,
}
// Save upserts record. The status secondary index is intrinsic to
// `games_status_created_idx` so callers see the same effect as the previous
// Redis adapter without the explicit index rewrite.
//
// The implementation is INSERT ... ON CONFLICT (game_id) DO UPDATE: the
// adapter cannot use plain INSERT because callers (notably the create-game
// service and admin updates) expect Save to be upsert.
func (store *Store) Save(ctx context.Context, record game.Game) error {
if store == nil || store.db == nil {
return errors.New("save game: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("save game: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save game", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
snapshot, err := marshalRuntimeSnapshot(record.RuntimeSnapshot)
if err != nil {
return fmt.Errorf("save game: %w", err)
}
binding, err := marshalRuntimeBinding(record.RuntimeBinding)
if err != nil {
return fmt.Errorf("save game: %w", err)
}
stmt := pgtable.Games.INSERT(
pgtable.Games.GameID,
pgtable.Games.GameName,
pgtable.Games.Description,
pgtable.Games.GameType,
pgtable.Games.OwnerUserID,
pgtable.Games.Status,
pgtable.Games.MinPlayers,
pgtable.Games.MaxPlayers,
pgtable.Games.StartGapHours,
pgtable.Games.StartGapPlayers,
pgtable.Games.EnrollmentEndsAt,
pgtable.Games.TurnSchedule,
pgtable.Games.TargetEngineVersion,
pgtable.Games.CreatedAt,
pgtable.Games.UpdatedAt,
pgtable.Games.StartedAt,
pgtable.Games.FinishedAt,
pgtable.Games.RuntimeSnapshot,
pgtable.Games.RuntimeBinding,
).VALUES(
record.GameID.String(),
record.GameName,
record.Description,
string(record.GameType),
record.OwnerUserID,
string(record.Status),
record.MinPlayers,
record.MaxPlayers,
record.StartGapHours,
record.StartGapPlayers,
record.EnrollmentEndsAt.UTC(),
record.TurnSchedule,
record.TargetEngineVersion,
record.CreatedAt.UTC(),
record.UpdatedAt.UTC(),
sqlx.NullableTimePtr(record.StartedAt),
sqlx.NullableTimePtr(record.FinishedAt),
snapshot,
binding,
).ON_CONFLICT(pgtable.Games.GameID).DO_UPDATE(
pg.SET(
pgtable.Games.GameName.SET(pgtable.Games.EXCLUDED.GameName),
pgtable.Games.Description.SET(pgtable.Games.EXCLUDED.Description),
pgtable.Games.GameType.SET(pgtable.Games.EXCLUDED.GameType),
pgtable.Games.OwnerUserID.SET(pgtable.Games.EXCLUDED.OwnerUserID),
pgtable.Games.Status.SET(pgtable.Games.EXCLUDED.Status),
pgtable.Games.MinPlayers.SET(pgtable.Games.EXCLUDED.MinPlayers),
pgtable.Games.MaxPlayers.SET(pgtable.Games.EXCLUDED.MaxPlayers),
pgtable.Games.StartGapHours.SET(pgtable.Games.EXCLUDED.StartGapHours),
pgtable.Games.StartGapPlayers.SET(pgtable.Games.EXCLUDED.StartGapPlayers),
pgtable.Games.EnrollmentEndsAt.SET(pgtable.Games.EXCLUDED.EnrollmentEndsAt),
pgtable.Games.TurnSchedule.SET(pgtable.Games.EXCLUDED.TurnSchedule),
pgtable.Games.TargetEngineVersion.SET(pgtable.Games.EXCLUDED.TargetEngineVersion),
pgtable.Games.UpdatedAt.SET(pgtable.Games.EXCLUDED.UpdatedAt),
pgtable.Games.StartedAt.SET(pgtable.Games.EXCLUDED.StartedAt),
pgtable.Games.FinishedAt.SET(pgtable.Games.EXCLUDED.FinishedAt),
pgtable.Games.RuntimeSnapshot.SET(pgtable.Games.EXCLUDED.RuntimeSnapshot),
pgtable.Games.RuntimeBinding.SET(pgtable.Games.EXCLUDED.RuntimeBinding),
),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("save game: %w", err)
}
return nil
}
// Get returns the record identified by gameID. It returns
// game.ErrNotFound when no record exists.
func (store *Store) Get(ctx context.Context, gameID common.GameID) (game.Game, error) {
if store == nil || store.db == nil {
return game.Game{}, errors.New("get game: nil store")
}
if err := gameID.Validate(); err != nil {
return game.Game{}, fmt.Errorf("get game: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get game", store.operationTimeout)
if err != nil {
return game.Game{}, err
}
defer cancel()
stmt := pg.SELECT(gameSelectColumns).
FROM(pgtable.Games).
WHERE(pgtable.Games.GameID.EQ(pg.String(gameID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanGame(row)
if sqlx.IsNoRows(err) {
return game.Game{}, game.ErrNotFound
}
if err != nil {
return game.Game{}, fmt.Errorf("get game: %w", err)
}
return record, nil
}
// GetByStatus returns every record whose status equals status. Records are
// sorted by created_at DESC then game_id DESC, matching the most-recent-first
// ordering Lobby's listing services expect.
func (store *Store) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) {
if store == nil || store.db == nil {
return nil, errors.New("get games by status: nil store")
}
if !status.IsKnown() {
return nil, fmt.Errorf("get games by status: status %q is unsupported", status)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get games by status", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(gameSelectColumns).
FROM(pgtable.Games).
WHERE(pgtable.Games.Status.EQ(pg.String(string(status)))).
ORDER_BY(pgtable.Games.CreatedAt.DESC(), pgtable.Games.GameID.DESC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("get games by status: %w", err)
}
defer rows.Close()
records, err := scanAllGames(rows)
if err != nil {
return nil, fmt.Errorf("get games by status: %w", err)
}
return records, nil
}
// CountByStatus returns the number of records under each known status.
func (store *Store) CountByStatus(ctx context.Context) (map[game.Status]int, error) {
if store == nil || store.db == nil {
return nil, errors.New("count games by status: nil store")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "count games by status", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
countAlias := pg.COUNT(pg.STAR).AS("count")
stmt := pg.SELECT(pgtable.Games.Status, countAlias).
FROM(pgtable.Games).
GROUP_BY(pgtable.Games.Status)
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("count games by status: %w", err)
}
defer rows.Close()
counts := make(map[game.Status]int, len(game.AllStatuses()))
for _, status := range game.AllStatuses() {
counts[status] = 0
}
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, fmt.Errorf("count games by status: scan: %w", err)
}
counts[game.Status(status)] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("count games by status: %w", err)
}
return counts, nil
}
// GetByOwner returns every record whose owner_user_id equals userID. The
// underlying `games_owner_idx` is partial (game_type = 'private'); public
// games carry an empty owner_user_id and are excluded from the index, matching
// the Redis-backed behaviour.
func (store *Store) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) {
if store == nil || store.db == nil {
return nil, errors.New("get games by owner: nil store")
}
trimmed := strings.TrimSpace(userID)
if trimmed == "" {
return nil, fmt.Errorf("get games by owner: user id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get games by owner", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(gameSelectColumns).
FROM(pgtable.Games).
WHERE(pgtable.Games.OwnerUserID.EQ(pg.String(trimmed))).
ORDER_BY(pgtable.Games.CreatedAt.DESC(), pgtable.Games.GameID.DESC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("get games by owner: %w", err)
}
defer rows.Close()
records, err := scanAllGames(rows)
if err != nil {
return nil, fmt.Errorf("get games by owner: %w", err)
}
return records, nil
}
// UpdateStatus applies one status transition with compare-and-swap on the
// current status column. The domain transition gate runs before any SQL
// touch.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
if store == nil || store.db == nil {
return errors.New("update game status: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update game status: %w", err)
}
if err := game.Transition(input.ExpectedFrom, input.To, input.Trigger); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update game status", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
at := input.At.UTC()
var startedAt, finishedAt any
if input.To == game.StatusRunning {
startedAt = at
}
if input.To == game.StatusFinished {
finishedAt = at
}
// COALESCE keeps the existing started_at/finished_at when the new value
// is NULL (the bind parameter is nil unless we are entering the
// running/finished state for the first time).
startedExpr := pg.COALESCE(pgtable.Games.StartedAt, pg.TimestampzT(at))
if startedAt == nil {
startedExpr = pgtable.Games.StartedAt
}
finishedExpr := pg.COALESCE(pgtable.Games.FinishedAt, pg.TimestampzT(at))
if finishedAt == nil {
finishedExpr = pgtable.Games.FinishedAt
}
stmt := pgtable.Games.UPDATE(
pgtable.Games.Status,
pgtable.Games.UpdatedAt,
pgtable.Games.StartedAt,
pgtable.Games.FinishedAt,
).SET(
pg.String(string(input.To)),
pg.TimestampzT(at),
startedExpr,
finishedExpr,
).WHERE(pg.AND(
pgtable.Games.GameID.EQ(pg.String(input.GameID.String())),
pgtable.Games.Status.EQ(pg.String(string(input.ExpectedFrom))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update game status: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update game status: rows affected: %w", err)
}
if affected == 0 {
// distinguish "not found" from "status mismatch" with a follow-up read
probe := pg.SELECT(pgtable.Games.Status).
FROM(pgtable.Games).
WHERE(pgtable.Games.GameID.EQ(pg.String(input.GameID.String())))
probeQuery, probeArgs := probe.Sql()
var current string
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
if err := row.Scan(&current); err != nil {
if sqlx.IsNoRows(err) {
return game.ErrNotFound
}
return fmt.Errorf("update game status: probe: %w", err)
}
return fmt.Errorf("update game status: %w", game.ErrConflict)
}
return nil
}
// UpdateRuntimeSnapshot overwrites the denormalised runtime snapshot fields.
func (store *Store) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime snapshot: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime snapshot", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
snapshot, err := marshalRuntimeSnapshot(input.Snapshot)
if err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
at := input.At.UTC()
stmt := pgtable.Games.UPDATE(pgtable.Games.RuntimeSnapshot, pgtable.Games.UpdatedAt).
SET(snapshot, at).
WHERE(pgtable.Games.GameID.EQ(pg.String(input.GameID.String())))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime snapshot: rows affected: %w", err)
}
if affected == 0 {
return game.ErrNotFound
}
return nil
}
// UpdateRuntimeBinding overwrites the runtime binding metadata.
func (store *Store) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime binding: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime binding", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
binding := input.Binding
encoded, err := marshalRuntimeBinding(&binding)
if err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
at := input.At.UTC()
stmt := pgtable.Games.UPDATE(pgtable.Games.RuntimeBinding, pgtable.Games.UpdatedAt).
SET(encoded, at).
WHERE(pgtable.Games.GameID.EQ(pg.String(input.GameID.String())))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime binding: rows affected: %w", err)
}
if affected == 0 {
return game.ErrNotFound
}
return nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanGame can be shared
// across both single-row reads and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanGame scans one games row from rs. Returns sql.ErrNoRows verbatim so
// callers can distinguish "no row" from a hard error.
func scanGame(rs rowScanner) (game.Game, error) {
var (
gameID string
gameName string
description string
gameType string
ownerUserID string
status string
minPlayers int
maxPlayers int
startGapHours int
startGapPlayers int
enrollmentEndsAt time.Time
turnSchedule string
targetEngineVersion string
createdAt time.Time
updatedAt time.Time
startedAt sql.NullTime
finishedAt sql.NullTime
runtimeSnapshot []byte
runtimeBinding []byte
)
if err := rs.Scan(
&gameID,
&gameName,
&description,
&gameType,
&ownerUserID,
&status,
&minPlayers,
&maxPlayers,
&startGapHours,
&startGapPlayers,
&enrollmentEndsAt,
&turnSchedule,
&targetEngineVersion,
&createdAt,
&updatedAt,
&startedAt,
&finishedAt,
&runtimeSnapshot,
&runtimeBinding,
); err != nil {
return game.Game{}, err
}
snapshot, err := unmarshalRuntimeSnapshot(runtimeSnapshot)
if err != nil {
return game.Game{}, err
}
binding, err := unmarshalRuntimeBinding(runtimeBinding)
if err != nil {
return game.Game{}, err
}
return game.Game{
GameID: common.GameID(gameID),
GameName: gameName,
Description: description,
GameType: game.GameType(gameType),
OwnerUserID: ownerUserID,
Status: game.Status(status),
MinPlayers: minPlayers,
MaxPlayers: maxPlayers,
StartGapHours: startGapHours,
StartGapPlayers: startGapPlayers,
EnrollmentEndsAt: enrollmentEndsAt.UTC(),
TurnSchedule: turnSchedule,
TargetEngineVersion: targetEngineVersion,
CreatedAt: createdAt.UTC(),
UpdatedAt: updatedAt.UTC(),
StartedAt: sqlx.TimePtrFromNullable(startedAt),
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
RuntimeSnapshot: snapshot,
RuntimeBinding: binding,
}, nil
}
func scanAllGames(rows *sql.Rows) ([]game.Game, error) {
records := make([]game.Game, 0)
for rows.Next() {
record, err := scanGame(rows)
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(records) == 0 {
return nil, nil
}
return records, nil
}
// Ensure Store satisfies the ports.GameStore interface at compile time.
var _ ports.GameStore = (*Store)(nil)
@@ -0,0 +1,338 @@
package gamestore_test
import (
"context"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/gamestore"
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *gamestore.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := gamestore.New(gamestore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
func fixturePublicGame(t *testing.T, id string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
record, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "Spring Classic " + id,
Description: "first public game",
GameType: game.GameTypePublic,
MinPlayers: 4,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.2.3",
Now: now,
})
require.NoError(t, err)
return record
}
func fixturePrivateGame(t *testing.T, id, ownerID string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
record, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "Private " + id,
GameType: game.GameTypePrivate,
OwnerUserID: ownerID,
MinPlayers: 2,
MaxPlayers: 6,
StartGapHours: 12,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.0.0",
Now: now,
})
require.NoError(t, err)
return record
}
func TestSaveAndGet(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, record.GameID, got.GameID)
assert.Equal(t, record.GameName, got.GameName)
assert.Equal(t, record.Status, got.Status)
assert.Equal(t, record.MinPlayers, got.MinPlayers)
assert.Equal(t, record.MaxPlayers, got.MaxPlayers)
assert.True(t, record.EnrollmentEndsAt.Equal(got.EnrollmentEndsAt))
assert.Equal(t, time.UTC, got.CreatedAt.Location())
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
}
func TestGetReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, common.GameID("game-missing-x"))
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestSaveIsUpsert(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
// edit a few fields, save again
record.GameName = "Renamed"
record.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, store.Save(ctx, record))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, "Renamed", got.GameName)
}
func TestUpdateStatusHappyPath(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: record.GameID,
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: record.UpdatedAt.Add(time.Minute),
}))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusEnrollmentOpen, got.Status)
}
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: record.GameID,
ExpectedFrom: game.StatusEnrollmentOpen, // wrong
To: game.StatusReadyToStart,
Trigger: game.TriggerManual,
At: record.UpdatedAt.Add(time.Minute),
})
require.ErrorIs(t, err, game.ErrConflict)
}
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: common.GameID("game-missing-x"),
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestUpdateStatusSetsStartedAtOnRunning(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
advance := func(from, to game.Status, trigger game.Trigger, at time.Time) {
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: record.GameID, ExpectedFrom: from, To: to, Trigger: trigger, At: at,
}))
}
now := record.UpdatedAt.Add(time.Minute)
advance(game.StatusDraft, game.StatusEnrollmentOpen, game.TriggerCommand, now)
advance(game.StatusEnrollmentOpen, game.StatusReadyToStart, game.TriggerManual, now.Add(time.Minute))
advance(game.StatusReadyToStart, game.StatusStarting, game.TriggerCommand, now.Add(2*time.Minute))
startedAt := now.Add(3 * time.Minute)
advance(game.StatusStarting, game.StatusRunning, game.TriggerRuntimeEvent, startedAt)
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusRunning, got.Status)
require.NotNil(t, got.StartedAt)
assert.True(t, got.StartedAt.Equal(startedAt))
}
func TestGetByStatusReturnsExpectedRecords(t *testing.T) {
ctx := context.Background()
store := newStore(t)
a := fixturePublicGame(t, "game-aaa")
b := fixturePublicGame(t, "game-bbb")
c := fixturePublicGame(t, "game-ccc")
for _, r := range []game.Game{a, b, c} {
require.NoError(t, store.Save(ctx, r))
}
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: b.GameID,
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: b.UpdatedAt.Add(time.Minute),
}))
drafts, err := store.GetByStatus(ctx, game.StatusDraft)
require.NoError(t, err)
gotIDs := map[common.GameID]struct{}{}
for _, r := range drafts {
gotIDs[r.GameID] = struct{}{}
}
assert.Contains(t, gotIDs, a.GameID)
assert.Contains(t, gotIDs, c.GameID)
assert.NotContains(t, gotIDs, b.GameID)
open, err := store.GetByStatus(ctx, game.StatusEnrollmentOpen)
require.NoError(t, err)
require.Len(t, open, 1)
assert.Equal(t, b.GameID, open[0].GameID)
}
func TestGetByOwnerOnlyReturnsPrivateGames(t *testing.T) {
ctx := context.Background()
store := newStore(t)
owner := "user-123"
pub := fixturePublicGame(t, "game-pub-001")
priv1 := fixturePrivateGame(t, "game-priv-001", owner)
priv2 := fixturePrivateGame(t, "game-priv-002", owner)
priv3 := fixturePrivateGame(t, "game-priv-003", "user-other")
for _, r := range []game.Game{pub, priv1, priv2, priv3} {
require.NoError(t, store.Save(ctx, r))
}
got, err := store.GetByOwner(ctx, owner)
require.NoError(t, err)
ids := map[common.GameID]struct{}{}
for _, r := range got {
ids[r.GameID] = struct{}{}
}
assert.Contains(t, ids, priv1.GameID)
assert.Contains(t, ids, priv2.GameID)
assert.NotContains(t, ids, priv3.GameID)
assert.NotContains(t, ids, pub.GameID)
}
func TestCountByStatusIncludesAllBuckets(t *testing.T) {
ctx := context.Background()
store := newStore(t)
require.NoError(t, store.Save(ctx, fixturePublicGame(t, "game-aaa")))
require.NoError(t, store.Save(ctx, fixturePublicGame(t, "game-bbb")))
counts, err := store.CountByStatus(ctx)
require.NoError(t, err)
for _, status := range game.AllStatuses() {
_, ok := counts[status]
assert.Truef(t, ok, "missing bucket for %q", status)
}
assert.Equal(t, 2, counts[game.StatusDraft])
}
func TestUpdateRuntimeSnapshotRoundTripsValues(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
snapshot := game.RuntimeSnapshot{
CurrentTurn: 42,
RuntimeStatus: "running_accepting_commands",
EngineHealthSummary: "ok",
}
require.NoError(t, store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
GameID: record.GameID,
Snapshot: snapshot,
At: record.UpdatedAt.Add(time.Minute),
}))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, snapshot, got.RuntimeSnapshot)
}
func TestUpdateRuntimeBindingRoundTripsValues(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
at := record.UpdatedAt.Add(time.Minute)
require.NoError(t, store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
GameID: record.GameID,
Binding: game.RuntimeBinding{
ContainerID: "container-7",
EngineEndpoint: "10.0.0.5:9000",
RuntimeJobID: "1700000000-0",
BoundAt: at,
},
At: at,
}))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
require.NotNil(t, got.RuntimeBinding)
assert.Equal(t, "container-7", got.RuntimeBinding.ContainerID)
assert.Equal(t, "10.0.0.5:9000", got.RuntimeBinding.EngineEndpoint)
assert.Equal(t, "1700000000-0", got.RuntimeBinding.RuntimeJobID)
assert.True(t, got.RuntimeBinding.BoundAt.Equal(at))
assert.Equal(t, time.UTC, got.RuntimeBinding.BoundAt.Location())
}
func TestUpdateRuntimeSnapshotReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
GameID: common.GameID("game-missing-x"),
Snapshot: game.RuntimeSnapshot{CurrentTurn: 1},
At: time.Now().UTC(),
})
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestNewRejectsNilDB(t *testing.T) {
_, err := gamestore.New(gamestore.Config{OperationTimeout: time.Second})
require.Error(t, err)
}
func TestNewRejectsNonPositiveTimeout(t *testing.T) {
_, err := gamestore.New(gamestore.Config{DB: pgtest.Ensure(t).Pool()})
require.Error(t, err)
}
@@ -0,0 +1,208 @@
// Package pgtest exposes the testcontainers-backed PostgreSQL bootstrap
// shared by every Game Lobby PG adapter test. The package is regular Go
// code — not a `_test.go` file — so it can be imported by the `_test.go`
// files in the four sibling store packages (`gamestore`, `applicationstore`,
// `invitestore`, `membershipstore`).
//
// No production code in `cmd/lobby` or in the runtime imports this package.
// The testcontainers-go dependency therefore stays out of the production
// binary's import graph.
package pgtest
import (
"context"
"database/sql"
"net/url"
"os"
"sync"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/migrations"
"galaxy/postgres"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
postgresImage = "postgres:16-alpine"
superUser = "galaxy"
superPassword = "galaxy"
superDatabase = "galaxy_lobby"
serviceRole = "lobbyservice"
servicePassword = "lobbyservice"
serviceSchema = "lobby"
containerStartup = 90 * time.Second
// OperationTimeout is the per-statement timeout used by every store
// constructed via NewStoreConfig. Tests may pass a smaller value if they
// need to assert deadline behaviour explicitly.
OperationTimeout = 10 * time.Second
)
// Env holds the per-process container plus the *sql.DB pool already
// provisioned with the lobby schema, role, and migrations applied.
type Env struct {
container *tcpostgres.PostgresContainer
pool *sql.DB
}
// Pool returns the shared pool. Tests truncate per-table state before each
// run via TruncateAll.
func (env *Env) Pool() *sql.DB { return env.pool }
var (
once sync.Once
cur *Env
curEr error
)
// Ensure starts the PostgreSQL container on first invocation and applies
// the embedded goose migrations. Subsequent invocations reuse the same
// container/pool. When Docker is unavailable Ensure calls t.Skip with the
// underlying error so the test suite still passes on machines without
// Docker.
func Ensure(t testing.TB) *Env {
t.Helper()
once.Do(func() {
cur, curEr = start()
})
if curEr != nil {
t.Skipf("postgres container start failed (Docker unavailable?): %v", curEr)
}
return cur
}
// TruncateAll wipes every Game Lobby table inside the shared pool, leaving
// the schema and indexes intact. Use it from each test that needs a clean
// slate.
func TruncateAll(t testing.TB) {
t.Helper()
env := Ensure(t)
const stmt = `TRUNCATE TABLE memberships, invites, applications, games, race_names RESTART IDENTITY CASCADE`
if _, err := env.pool.ExecContext(context.Background(), stmt); err != nil {
t.Fatalf("truncate lobby tables: %v", err)
}
}
// Shutdown terminates the shared container and closes the pool. It is
// invoked from each test package's TestMain after `m.Run` returns so the
// container is released even if individual tests panic.
func Shutdown() {
if cur == nil {
return
}
if cur.pool != nil {
_ = cur.pool.Close()
}
if cur.container != nil {
_ = testcontainers.TerminateContainer(cur.container)
}
cur = nil
}
// RunMain is a convenience helper for each store package's TestMain: it
// runs the test main, captures the exit code, shuts the container down, and
// exits. Wiring it through one helper keeps every TestMain to two lines.
func RunMain(m *testing.M) {
code := m.Run()
Shutdown()
os.Exit(code)
}
func start() (*Env, error) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, postgresImage,
tcpostgres.WithDatabase(superDatabase),
tcpostgres.WithUsername(superUser),
tcpostgres.WithPassword(superPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
return nil, err
}
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
scopedDSN, err := dsnForServiceRole(baseDSN)
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = OperationTimeout
pool, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := postgres.Ping(ctx, pool, OperationTimeout); err != nil {
_ = pool.Close()
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
_ = pool.Close()
_ = testcontainers.TerminateContainer(container)
return nil, err
}
return &Env{container: container, pool: pool}, nil
}
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = baseDSN
cfg.OperationTimeout = OperationTimeout
db, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
statements := []string{
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'lobbyservice') THEN
CREATE ROLE lobbyservice LOGIN PASSWORD 'lobbyservice';
END IF;
END $$;`,
`CREATE SCHEMA IF NOT EXISTS lobby AUTHORIZATION lobbyservice;`,
`GRANT USAGE ON SCHEMA lobby TO lobbyservice;`,
}
for _, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func dsnForServiceRole(baseDSN string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := url.Values{}
values.Set("search_path", serviceSchema)
values.Set("sslmode", "disable")
scoped := url.URL{
Scheme: parsed.Scheme,
User: url.UserPassword(serviceRole, servicePassword),
Host: parsed.Host,
Path: parsed.Path,
RawQuery: values.Encode(),
}
return scoped.String(), nil
}
@@ -0,0 +1,96 @@
// Package sqlx contains the small set of helpers shared by every Game Lobby
// PostgreSQL adapter (gamestore, applicationstore, invitestore,
// membershipstore). The helpers centralise the boundary translations from
// the per-service ARCHITECTURE.md timestamp-handling rules and from the pgx
// SQLSTATE codes the adapters interpret as domain conflicts.
package sqlx
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// PgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL when
// a UNIQUE constraint is violated by INSERT or UPDATE.
const PgUniqueViolationCode = "23505"
// IsUniqueViolation reports whether err is a PostgreSQL unique-violation,
// regardless of constraint name.
func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
return pgErr.Code == PgUniqueViolationCode
}
// IsNoRows reports whether err is sql.ErrNoRows.
func IsNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}
// NullableTime returns t.UTC() when non-zero, otherwise nil so the column
// is bound as SQL NULL. Several Lobby domain records use *time.Time to
// express absent timestamps; for those, callers translate the pointer with
// NullableTimePtr instead.
func NullableTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.UTC()
}
// NullableTimePtr returns t.UTC() when t is non-nil and non-zero, otherwise
// nil. The helper is the *time.Time companion of NullableTime: every Lobby
// domain record has at least one optional `*time.Time` field
// (`StartedAt`, `FinishedAt`, `DecidedAt`, `RemovedAt`) that maps to a
// nullable timestamptz column.
func NullableTimePtr(t *time.Time) any {
if t == nil {
return nil
}
return NullableTime(*t)
}
// TimeFromNullable copies an optional sql.NullTime read from PostgreSQL
// into a domain time.Time, applying the global UTC normalisation rule.
// Invalid (NULL) values become the zero time.Time.
func TimeFromNullable(value sql.NullTime) time.Time {
if !value.Valid {
return time.Time{}
}
return value.Time.UTC()
}
// TimePtrFromNullable copies an optional sql.NullTime into a domain
// *time.Time. NULL becomes nil; non-NULL values are wrapped after UTC
// normalisation.
func TimePtrFromNullable(value sql.NullTime) *time.Time {
if !value.Valid {
return nil
}
t := value.Time.UTC()
return &t
}
// WithTimeout derives a child context bounded by timeout and prefixes
// context errors with operation. Callers must always invoke the returned
// cancel.
func WithTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
if ctx == nil {
return nil, nil, fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("%s: %w", operation, err)
}
if timeout <= 0 {
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
}
bounded, cancel := context.WithTimeout(ctx, timeout)
return bounded, cancel, nil
}
@@ -0,0 +1,348 @@
// Package invitestore implements the PostgreSQL-backed adapter for
// `ports.InviteStore`.
//
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
// invite records.
package invitestore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/invite"
"galaxy/lobby/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed invite store instance.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Lobby invite records in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed invite store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres invite store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres invite store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// inviteSelectColumns is the canonical SELECT list for the invites table,
// matching scanInvite's column order.
var inviteSelectColumns = pg.ColumnList{
pgtable.Invites.InviteID,
pgtable.Invites.GameID,
pgtable.Invites.InviterUserID,
pgtable.Invites.InviteeUserID,
pgtable.Invites.RaceName,
pgtable.Invites.Status,
pgtable.Invites.CreatedAt,
pgtable.Invites.ExpiresAt,
pgtable.Invites.DecidedAt,
}
// Save persists a new created invite record. Save is create-only; a second
// save against the same invite id maps the unique-violation to
// invite.ErrConflict.
func (store *Store) Save(ctx context.Context, record invite.Invite) error {
if store == nil || store.db == nil {
return errors.New("save invite: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("save invite: %w", err)
}
if record.Status != invite.StatusCreated {
return fmt.Errorf(
"save invite: status must be %q, got %q",
invite.StatusCreated, record.Status,
)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save invite", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.Invites.INSERT(
pgtable.Invites.InviteID,
pgtable.Invites.GameID,
pgtable.Invites.InviterUserID,
pgtable.Invites.InviteeUserID,
pgtable.Invites.RaceName,
pgtable.Invites.Status,
pgtable.Invites.CreatedAt,
pgtable.Invites.ExpiresAt,
pgtable.Invites.DecidedAt,
).VALUES(
record.InviteID.String(),
record.GameID.String(),
record.InviterUserID,
record.InviteeUserID,
record.RaceName,
string(record.Status),
record.CreatedAt.UTC(),
record.ExpiresAt.UTC(),
sqlx.NullableTimePtr(record.DecidedAt),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("save invite: %w", invite.ErrConflict)
}
return fmt.Errorf("save invite: %w", err)
}
return nil
}
// Get returns the record identified by inviteID.
func (store *Store) Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) {
if store == nil || store.db == nil {
return invite.Invite{}, errors.New("get invite: nil store")
}
if err := inviteID.Validate(); err != nil {
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get invite", store.operationTimeout)
if err != nil {
return invite.Invite{}, err
}
defer cancel()
stmt := pg.SELECT(inviteSelectColumns).
FROM(pgtable.Invites).
WHERE(pgtable.Invites.InviteID.EQ(pg.String(inviteID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanInvite(row)
if sqlx.IsNoRows(err) {
return invite.Invite{}, invite.ErrNotFound
}
if err != nil {
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
}
return record, nil
}
// GetByGame returns every invite attached to gameID.
func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) {
if store == nil || store.db == nil {
return nil, errors.New("get invites by game: nil store")
}
if err := gameID.Validate(); err != nil {
return nil, fmt.Errorf("get invites by game: %w", err)
}
stmt := pg.SELECT(inviteSelectColumns).
FROM(pgtable.Invites).
WHERE(pgtable.Invites.GameID.EQ(pg.String(gameID.String()))).
ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC())
return store.queryList(ctx, "get invites by game", stmt)
}
// GetByUser returns every invite addressed to inviteeUserID.
func (store *Store) GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) {
if store == nil || store.db == nil {
return nil, errors.New("get invites by user: nil store")
}
trimmed := strings.TrimSpace(inviteeUserID)
if trimmed == "" {
return nil, fmt.Errorf("get invites by user: invitee user id must not be empty")
}
stmt := pg.SELECT(inviteSelectColumns).
FROM(pgtable.Invites).
WHERE(pgtable.Invites.InviteeUserID.EQ(pg.String(trimmed))).
ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC())
return store.queryList(ctx, "get invites by user", stmt)
}
// GetByInviter returns every invite created by inviterUserID.
func (store *Store) GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) {
if store == nil || store.db == nil {
return nil, errors.New("get invites by inviter: nil store")
}
trimmed := strings.TrimSpace(inviterUserID)
if trimmed == "" {
return nil, fmt.Errorf("get invites by inviter: inviter user id must not be empty")
}
stmt := pg.SELECT(inviteSelectColumns).
FROM(pgtable.Invites).
WHERE(pgtable.Invites.InviterUserID.EQ(pg.String(trimmed))).
ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC())
return store.queryList(ctx, "get invites by inviter", stmt)
}
func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]invite.Invite, error) {
operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
defer rows.Close()
records := make([]invite.Invite, 0)
for rows.Next() {
record, err := scanInvite(rows)
if err != nil {
return nil, fmt.Errorf("%s: scan: %w", operation, err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
if len(records) == 0 {
return nil, nil
}
return records, nil
}
// UpdateStatus applies one status transition with compare-and-swap on the
// current status column. When transitioning to redeemed the row's race_name
// column is replaced with the trimmed input value.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateInviteStatusInput) error {
if store == nil || store.db == nil {
return errors.New("update invite status: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update invite status: %w", err)
}
if err := invite.Transition(input.ExpectedFrom, input.To); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update invite status", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
at := input.At.UTC()
raceName := strings.TrimSpace(input.RaceName)
// race_name is replaced only when the caller supplies a non-empty value;
// otherwise the existing value is preserved (CASE WHEN '' THEN race_name).
raceExpr := pg.CASE().
WHEN(pg.String(raceName).EQ(pg.String(""))).THEN(pgtable.Invites.RaceName).
ELSE(pg.String(raceName))
stmt := pgtable.Invites.UPDATE(
pgtable.Invites.Status,
pgtable.Invites.DecidedAt,
pgtable.Invites.RaceName,
).SET(
pg.String(string(input.To)),
pg.TimestampzT(at),
raceExpr,
).WHERE(pg.AND(
pgtable.Invites.InviteID.EQ(pg.String(input.InviteID.String())),
pgtable.Invites.Status.EQ(pg.String(string(input.ExpectedFrom))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update invite status: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update invite status: rows affected: %w", err)
}
if affected == 0 {
probe := pg.SELECT(pgtable.Invites.Status).
FROM(pgtable.Invites).
WHERE(pgtable.Invites.InviteID.EQ(pg.String(input.InviteID.String())))
probeQuery, probeArgs := probe.Sql()
var current string
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
if err := row.Scan(&current); err != nil {
if sqlx.IsNoRows(err) {
return invite.ErrNotFound
}
return fmt.Errorf("update invite status: probe: %w", err)
}
return fmt.Errorf("update invite status: %w", invite.ErrConflict)
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanInvite(rs rowScanner) (invite.Invite, error) {
var (
inviteID string
gameID string
inviterUserID string
inviteeUserID string
raceName string
status string
createdAt time.Time
expiresAt time.Time
decidedAt sql.NullTime
)
if err := rs.Scan(
&inviteID,
&gameID,
&inviterUserID,
&inviteeUserID,
&raceName,
&status,
&createdAt,
&expiresAt,
&decidedAt,
); err != nil {
return invite.Invite{}, err
}
return invite.Invite{
InviteID: common.InviteID(inviteID),
GameID: common.GameID(gameID),
InviterUserID: inviterUserID,
InviteeUserID: inviteeUserID,
RaceName: raceName,
Status: invite.Status(status),
CreatedAt: createdAt.UTC(),
ExpiresAt: expiresAt.UTC(),
DecidedAt: sqlx.TimePtrFromNullable(decidedAt),
}, nil
}
// Ensure Store satisfies the ports.InviteStore interface at compile time.
var _ ports.InviteStore = (*Store)(nil)
@@ -0,0 +1,199 @@
package invitestore_test
import (
"context"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/gamestore"
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
"galaxy/lobby/internal/adapters/postgres/invitestore"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/invite"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStores(t *testing.T) (*gamestore.Store, *invitestore.Store) {
t.Helper()
pgtest.TruncateAll(t)
gs, err := gamestore.New(gamestore.Config{
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
is, err := invitestore.New(invitestore.Config{
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return gs, is
}
func seedPrivateGame(t *testing.T, gs *gamestore.Store, id, ownerID string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
g, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "Private " + id,
GameType: game.GameTypePrivate,
OwnerUserID: ownerID,
MinPlayers: 2,
MaxPlayers: 6,
StartGapHours: 12,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.0.0",
Now: now,
})
require.NoError(t, err)
require.NoError(t, gs.Save(context.Background(), g))
return g
}
func newInvite(t *testing.T, id, gameID, inviter, invitee string) invite.Invite {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
rec, err := invite.New(invite.NewInviteInput{
InviteID: common.InviteID(id),
GameID: common.GameID(gameID),
InviterUserID: inviter,
InviteeUserID: invitee,
Now: now,
ExpiresAt: now.Add(7 * 24 * time.Hour),
})
require.NoError(t, err)
return rec
}
func TestSaveAndGet(t *testing.T) {
ctx := context.Background()
gs, is := newStores(t)
seedPrivateGame(t, gs, "game-001", "owner-1")
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
require.NoError(t, is.Save(ctx, rec))
got, err := is.Get(ctx, rec.InviteID)
require.NoError(t, err)
assert.Equal(t, rec.InviteID, got.InviteID)
assert.Equal(t, invite.StatusCreated, got.Status)
assert.Equal(t, "invitee-1", got.InviteeUserID)
assert.True(t, got.ExpiresAt.Equal(rec.ExpiresAt))
}
func TestSaveRejectsNonCreated(t *testing.T) {
ctx := context.Background()
gs, is := newStores(t)
seedPrivateGame(t, gs, "game-001", "owner-1")
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
rec.Status = invite.StatusRedeemed
require.Error(t, is.Save(ctx, rec))
}
func TestSaveDuplicateReturnsConflict(t *testing.T) {
ctx := context.Background()
gs, is := newStores(t)
seedPrivateGame(t, gs, "game-001", "owner-1")
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
require.NoError(t, is.Save(ctx, rec))
err := is.Save(ctx, rec)
require.ErrorIs(t, err, invite.ErrConflict)
}
func TestUpdateStatusRedeemSetsRaceName(t *testing.T) {
ctx := context.Background()
gs, is := newStores(t)
seedPrivateGame(t, gs, "game-001", "owner-1")
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
require.NoError(t, is.Save(ctx, rec))
require.NoError(t, is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
InviteID: rec.InviteID,
ExpectedFrom: invite.StatusCreated,
To: invite.StatusRedeemed,
At: rec.CreatedAt.Add(time.Minute),
RaceName: "PilotRedeemed",
}))
got, err := is.Get(ctx, rec.InviteID)
require.NoError(t, err)
assert.Equal(t, invite.StatusRedeemed, got.Status)
assert.Equal(t, "PilotRedeemed", got.RaceName)
require.NotNil(t, got.DecidedAt)
}
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
ctx := context.Background()
gs, is := newStores(t)
seedPrivateGame(t, gs, "game-001", "owner-1")
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
require.NoError(t, is.Save(ctx, rec))
// Move row out of `created` so the next attempt's `WHERE status = ?`
// fails on persistence even though the (created → revoked) transition is
// itself valid in the domain table.
require.NoError(t, is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
InviteID: rec.InviteID,
ExpectedFrom: invite.StatusCreated,
To: invite.StatusDeclined,
At: rec.CreatedAt.Add(time.Minute),
}))
err := is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
InviteID: rec.InviteID,
ExpectedFrom: invite.StatusCreated,
To: invite.StatusRevoked,
At: rec.CreatedAt.Add(2 * time.Minute),
})
require.ErrorIs(t, err, invite.ErrConflict)
}
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
_, is := newStores(t)
err := is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
InviteID: common.InviteID("invite-missing"),
ExpectedFrom: invite.StatusCreated,
To: invite.StatusDeclined,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, invite.ErrNotFound)
}
func TestGetByGameUserInviter(t *testing.T) {
ctx := context.Background()
gs, is := newStores(t)
seedPrivateGame(t, gs, "game-001", "owner-1")
seedPrivateGame(t, gs, "game-002", "owner-2")
require.NoError(t, is.Save(ctx, newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")))
require.NoError(t, is.Save(ctx, newInvite(t, "invite-002", "game-001", "owner-1", "invitee-2")))
require.NoError(t, is.Save(ctx, newInvite(t, "invite-003", "game-002", "owner-2", "invitee-1")))
g1, err := is.GetByGame(ctx, common.GameID("game-001"))
require.NoError(t, err)
assert.Len(t, g1, 2)
user1, err := is.GetByUser(ctx, "invitee-1")
require.NoError(t, err)
assert.Len(t, user1, 2)
by1, err := is.GetByInviter(ctx, "owner-1")
require.NoError(t, err)
assert.Len(t, by1, 2)
}
func TestGetMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
_, is := newStores(t)
_, err := is.Get(ctx, common.InviteID("invite-missing"))
require.ErrorIs(t, err, invite.ErrNotFound)
}
@@ -0,0 +1,22 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type Applications struct {
ApplicationID string `sql:"primary_key"`
GameID string
ApplicantUserID string
RaceName string
Status string
CreatedAt time.Time
DecidedAt *time.Time
}
@@ -0,0 +1,34 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type Games struct {
GameID string `sql:"primary_key"`
GameName string
Description string
GameType string
OwnerUserID string
Status string
MinPlayers int32
MaxPlayers int32
StartGapHours int32
StartGapPlayers int32
EnrollmentEndsAt time.Time
TurnSchedule string
TargetEngineVersion string
CreatedAt time.Time
UpdatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
RuntimeSnapshot string
RuntimeBinding *string
}
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type GooseDbVersion struct {
ID int32 `sql:"primary_key"`
VersionID int64
IsApplied bool
Tstamp time.Time
}
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type Invites struct {
InviteID string `sql:"primary_key"`
GameID string
InviterUserID string
InviteeUserID string
RaceName string
Status string
CreatedAt time.Time
ExpiresAt time.Time
DecidedAt *time.Time
}
@@ -0,0 +1,23 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type Memberships struct {
MembershipID string `sql:"primary_key"`
GameID string
UserID string
RaceName string
CanonicalKey string
Status string
JoinedAt time.Time
RemovedAt *time.Time
}
@@ -0,0 +1,20 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
type RaceNames struct {
CanonicalKey string `sql:"primary_key"`
GameID string `sql:"primary_key"`
HolderUserID string
RaceName string
BindingKind string
SourceGameID string
ReservedAtMs int64
EligibleUntilMs *int64
RegisteredAtMs *int64
}
@@ -0,0 +1,96 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Applications = newApplicationsTable("lobby", "applications", "")
type applicationsTable struct {
postgres.Table
// Columns
ApplicationID postgres.ColumnString
GameID postgres.ColumnString
ApplicantUserID postgres.ColumnString
RaceName postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
DecidedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ApplicationsTable struct {
applicationsTable
EXCLUDED applicationsTable
}
// AS creates new ApplicationsTable with assigned alias
func (a ApplicationsTable) AS(alias string) *ApplicationsTable {
return newApplicationsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ApplicationsTable with assigned schema name
func (a ApplicationsTable) FromSchema(schemaName string) *ApplicationsTable {
return newApplicationsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ApplicationsTable with assigned table prefix
func (a ApplicationsTable) WithPrefix(prefix string) *ApplicationsTable {
return newApplicationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ApplicationsTable with assigned table suffix
func (a ApplicationsTable) WithSuffix(suffix string) *ApplicationsTable {
return newApplicationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newApplicationsTable(schemaName, tableName, alias string) *ApplicationsTable {
return &ApplicationsTable{
applicationsTable: newApplicationsTableImpl(schemaName, tableName, alias),
EXCLUDED: newApplicationsTableImpl("", "excluded", ""),
}
}
func newApplicationsTableImpl(schemaName, tableName, alias string) applicationsTable {
var (
ApplicationIDColumn = postgres.StringColumn("application_id")
GameIDColumn = postgres.StringColumn("game_id")
ApplicantUserIDColumn = postgres.StringColumn("applicant_user_id")
RaceNameColumn = postgres.StringColumn("race_name")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
DecidedAtColumn = postgres.TimestampzColumn("decided_at")
allColumns = postgres.ColumnList{ApplicationIDColumn, GameIDColumn, ApplicantUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, DecidedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, ApplicantUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, DecidedAtColumn}
defaultColumns = postgres.ColumnList{}
)
return applicationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ApplicationID: ApplicationIDColumn,
GameID: GameIDColumn,
ApplicantUserID: ApplicantUserIDColumn,
RaceName: RaceNameColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
DecidedAt: DecidedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,132 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Games = newGamesTable("lobby", "games", "")
type gamesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
GameName postgres.ColumnString
Description postgres.ColumnString
GameType postgres.ColumnString
OwnerUserID postgres.ColumnString
Status postgres.ColumnString
MinPlayers postgres.ColumnInteger
MaxPlayers postgres.ColumnInteger
StartGapHours postgres.ColumnInteger
StartGapPlayers postgres.ColumnInteger
EnrollmentEndsAt postgres.ColumnTimestampz
TurnSchedule postgres.ColumnString
TargetEngineVersion postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
StartedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
RuntimeSnapshot postgres.ColumnString
RuntimeBinding postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GamesTable struct {
gamesTable
EXCLUDED gamesTable
}
// AS creates new GamesTable with assigned alias
func (a GamesTable) AS(alias string) *GamesTable {
return newGamesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GamesTable with assigned schema name
func (a GamesTable) FromSchema(schemaName string) *GamesTable {
return newGamesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GamesTable with assigned table prefix
func (a GamesTable) WithPrefix(prefix string) *GamesTable {
return newGamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GamesTable with assigned table suffix
func (a GamesTable) WithSuffix(suffix string) *GamesTable {
return newGamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGamesTable(schemaName, tableName, alias string) *GamesTable {
return &GamesTable{
gamesTable: newGamesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGamesTableImpl("", "excluded", ""),
}
}
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
GameNameColumn = postgres.StringColumn("game_name")
DescriptionColumn = postgres.StringColumn("description")
GameTypeColumn = postgres.StringColumn("game_type")
OwnerUserIDColumn = postgres.StringColumn("owner_user_id")
StatusColumn = postgres.StringColumn("status")
MinPlayersColumn = postgres.IntegerColumn("min_players")
MaxPlayersColumn = postgres.IntegerColumn("max_players")
StartGapHoursColumn = postgres.IntegerColumn("start_gap_hours")
StartGapPlayersColumn = postgres.IntegerColumn("start_gap_players")
EnrollmentEndsAtColumn = postgres.TimestampzColumn("enrollment_ends_at")
TurnScheduleColumn = postgres.StringColumn("turn_schedule")
TargetEngineVersionColumn = postgres.StringColumn("target_engine_version")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
StartedAtColumn = postgres.TimestampzColumn("started_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
RuntimeSnapshotColumn = postgres.StringColumn("runtime_snapshot")
RuntimeBindingColumn = postgres.StringColumn("runtime_binding")
allColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, DescriptionColumn, GameTypeColumn, OwnerUserIDColumn, StatusColumn, MinPlayersColumn, MaxPlayersColumn, StartGapHoursColumn, StartGapPlayersColumn, EnrollmentEndsAtColumn, TurnScheduleColumn, TargetEngineVersionColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, FinishedAtColumn, RuntimeSnapshotColumn, RuntimeBindingColumn}
mutableColumns = postgres.ColumnList{GameNameColumn, DescriptionColumn, GameTypeColumn, OwnerUserIDColumn, StatusColumn, MinPlayersColumn, MaxPlayersColumn, StartGapHoursColumn, StartGapPlayersColumn, EnrollmentEndsAtColumn, TurnScheduleColumn, TargetEngineVersionColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, FinishedAtColumn, RuntimeSnapshotColumn, RuntimeBindingColumn}
defaultColumns = postgres.ColumnList{DescriptionColumn, OwnerUserIDColumn, RuntimeSnapshotColumn}
)
return gamesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
GameName: GameNameColumn,
Description: DescriptionColumn,
GameType: GameTypeColumn,
OwnerUserID: OwnerUserIDColumn,
Status: StatusColumn,
MinPlayers: MinPlayersColumn,
MaxPlayers: MaxPlayersColumn,
StartGapHours: StartGapHoursColumn,
StartGapPlayers: StartGapPlayersColumn,
EnrollmentEndsAt: EnrollmentEndsAtColumn,
TurnSchedule: TurnScheduleColumn,
TargetEngineVersion: TargetEngineVersionColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
StartedAt: StartedAtColumn,
FinishedAt: FinishedAtColumn,
RuntimeSnapshot: RuntimeSnapshotColumn,
RuntimeBinding: RuntimeBindingColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,87 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GooseDbVersion = newGooseDbVersionTable("lobby", "goose_db_version", "")
type gooseDbVersionTable struct {
postgres.Table
// Columns
ID postgres.ColumnInteger
VersionID postgres.ColumnInteger
IsApplied postgres.ColumnBool
Tstamp postgres.ColumnTimestamp
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GooseDbVersionTable struct {
gooseDbVersionTable
EXCLUDED gooseDbVersionTable
}
// AS creates new GooseDbVersionTable with assigned alias
func (a GooseDbVersionTable) AS(alias string) *GooseDbVersionTable {
return newGooseDbVersionTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GooseDbVersionTable with assigned schema name
func (a GooseDbVersionTable) FromSchema(schemaName string) *GooseDbVersionTable {
return newGooseDbVersionTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GooseDbVersionTable with assigned table prefix
func (a GooseDbVersionTable) WithPrefix(prefix string) *GooseDbVersionTable {
return newGooseDbVersionTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GooseDbVersionTable with assigned table suffix
func (a GooseDbVersionTable) WithSuffix(suffix string) *GooseDbVersionTable {
return newGooseDbVersionTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGooseDbVersionTable(schemaName, tableName, alias string) *GooseDbVersionTable {
return &GooseDbVersionTable{
gooseDbVersionTable: newGooseDbVersionTableImpl(schemaName, tableName, alias),
EXCLUDED: newGooseDbVersionTableImpl("", "excluded", ""),
}
}
func newGooseDbVersionTableImpl(schemaName, tableName, alias string) gooseDbVersionTable {
var (
IDColumn = postgres.IntegerColumn("id")
VersionIDColumn = postgres.IntegerColumn("version_id")
IsAppliedColumn = postgres.BoolColumn("is_applied")
TstampColumn = postgres.TimestampColumn("tstamp")
allColumns = postgres.ColumnList{IDColumn, VersionIDColumn, IsAppliedColumn, TstampColumn}
mutableColumns = postgres.ColumnList{VersionIDColumn, IsAppliedColumn, TstampColumn}
defaultColumns = postgres.ColumnList{TstampColumn}
)
return gooseDbVersionTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
VersionID: VersionIDColumn,
IsApplied: IsAppliedColumn,
Tstamp: TstampColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,102 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Invites = newInvitesTable("lobby", "invites", "")
type invitesTable struct {
postgres.Table
// Columns
InviteID postgres.ColumnString
GameID postgres.ColumnString
InviterUserID postgres.ColumnString
InviteeUserID postgres.ColumnString
RaceName postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
ExpiresAt postgres.ColumnTimestampz
DecidedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type InvitesTable struct {
invitesTable
EXCLUDED invitesTable
}
// AS creates new InvitesTable with assigned alias
func (a InvitesTable) AS(alias string) *InvitesTable {
return newInvitesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new InvitesTable with assigned schema name
func (a InvitesTable) FromSchema(schemaName string) *InvitesTable {
return newInvitesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new InvitesTable with assigned table prefix
func (a InvitesTable) WithPrefix(prefix string) *InvitesTable {
return newInvitesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new InvitesTable with assigned table suffix
func (a InvitesTable) WithSuffix(suffix string) *InvitesTable {
return newInvitesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newInvitesTable(schemaName, tableName, alias string) *InvitesTable {
return &InvitesTable{
invitesTable: newInvitesTableImpl(schemaName, tableName, alias),
EXCLUDED: newInvitesTableImpl("", "excluded", ""),
}
}
func newInvitesTableImpl(schemaName, tableName, alias string) invitesTable {
var (
InviteIDColumn = postgres.StringColumn("invite_id")
GameIDColumn = postgres.StringColumn("game_id")
InviterUserIDColumn = postgres.StringColumn("inviter_user_id")
InviteeUserIDColumn = postgres.StringColumn("invitee_user_id")
RaceNameColumn = postgres.StringColumn("race_name")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
DecidedAtColumn = postgres.TimestampzColumn("decided_at")
allColumns = postgres.ColumnList{InviteIDColumn, GameIDColumn, InviterUserIDColumn, InviteeUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, ExpiresAtColumn, DecidedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, InviterUserIDColumn, InviteeUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, ExpiresAtColumn, DecidedAtColumn}
defaultColumns = postgres.ColumnList{RaceNameColumn}
)
return invitesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InviteID: InviteIDColumn,
GameID: GameIDColumn,
InviterUserID: InviterUserIDColumn,
InviteeUserID: InviteeUserIDColumn,
RaceName: RaceNameColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
ExpiresAt: ExpiresAtColumn,
DecidedAt: DecidedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Memberships = newMembershipsTable("lobby", "memberships", "")
type membershipsTable struct {
postgres.Table
// Columns
MembershipID postgres.ColumnString
GameID postgres.ColumnString
UserID postgres.ColumnString
RaceName postgres.ColumnString
CanonicalKey postgres.ColumnString
Status postgres.ColumnString
JoinedAt postgres.ColumnTimestampz
RemovedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type MembershipsTable struct {
membershipsTable
EXCLUDED membershipsTable
}
// AS creates new MembershipsTable with assigned alias
func (a MembershipsTable) AS(alias string) *MembershipsTable {
return newMembershipsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new MembershipsTable with assigned schema name
func (a MembershipsTable) FromSchema(schemaName string) *MembershipsTable {
return newMembershipsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new MembershipsTable with assigned table prefix
func (a MembershipsTable) WithPrefix(prefix string) *MembershipsTable {
return newMembershipsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new MembershipsTable with assigned table suffix
func (a MembershipsTable) WithSuffix(suffix string) *MembershipsTable {
return newMembershipsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newMembershipsTable(schemaName, tableName, alias string) *MembershipsTable {
return &MembershipsTable{
membershipsTable: newMembershipsTableImpl(schemaName, tableName, alias),
EXCLUDED: newMembershipsTableImpl("", "excluded", ""),
}
}
func newMembershipsTableImpl(schemaName, tableName, alias string) membershipsTable {
var (
MembershipIDColumn = postgres.StringColumn("membership_id")
GameIDColumn = postgres.StringColumn("game_id")
UserIDColumn = postgres.StringColumn("user_id")
RaceNameColumn = postgres.StringColumn("race_name")
CanonicalKeyColumn = postgres.StringColumn("canonical_key")
StatusColumn = postgres.StringColumn("status")
JoinedAtColumn = postgres.TimestampzColumn("joined_at")
RemovedAtColumn = postgres.TimestampzColumn("removed_at")
allColumns = postgres.ColumnList{MembershipIDColumn, GameIDColumn, UserIDColumn, RaceNameColumn, CanonicalKeyColumn, StatusColumn, JoinedAtColumn, RemovedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, UserIDColumn, RaceNameColumn, CanonicalKeyColumn, StatusColumn, JoinedAtColumn, RemovedAtColumn}
defaultColumns = postgres.ColumnList{}
)
return membershipsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MembershipID: MembershipIDColumn,
GameID: GameIDColumn,
UserID: UserIDColumn,
RaceName: RaceNameColumn,
CanonicalKey: CanonicalKeyColumn,
Status: StatusColumn,
JoinedAt: JoinedAtColumn,
RemovedAt: RemovedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,102 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var RaceNames = newRaceNamesTable("lobby", "race_names", "")
type raceNamesTable struct {
postgres.Table
// Columns
CanonicalKey postgres.ColumnString
GameID postgres.ColumnString
HolderUserID postgres.ColumnString
RaceName postgres.ColumnString
BindingKind postgres.ColumnString
SourceGameID postgres.ColumnString
ReservedAtMs postgres.ColumnInteger
EligibleUntilMs postgres.ColumnInteger
RegisteredAtMs postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RaceNamesTable struct {
raceNamesTable
EXCLUDED raceNamesTable
}
// AS creates new RaceNamesTable with assigned alias
func (a RaceNamesTable) AS(alias string) *RaceNamesTable {
return newRaceNamesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RaceNamesTable with assigned schema name
func (a RaceNamesTable) FromSchema(schemaName string) *RaceNamesTable {
return newRaceNamesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RaceNamesTable with assigned table prefix
func (a RaceNamesTable) WithPrefix(prefix string) *RaceNamesTable {
return newRaceNamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RaceNamesTable with assigned table suffix
func (a RaceNamesTable) WithSuffix(suffix string) *RaceNamesTable {
return newRaceNamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRaceNamesTable(schemaName, tableName, alias string) *RaceNamesTable {
return &RaceNamesTable{
raceNamesTable: newRaceNamesTableImpl(schemaName, tableName, alias),
EXCLUDED: newRaceNamesTableImpl("", "excluded", ""),
}
}
func newRaceNamesTableImpl(schemaName, tableName, alias string) raceNamesTable {
var (
CanonicalKeyColumn = postgres.StringColumn("canonical_key")
GameIDColumn = postgres.StringColumn("game_id")
HolderUserIDColumn = postgres.StringColumn("holder_user_id")
RaceNameColumn = postgres.StringColumn("race_name")
BindingKindColumn = postgres.StringColumn("binding_kind")
SourceGameIDColumn = postgres.StringColumn("source_game_id")
ReservedAtMsColumn = postgres.IntegerColumn("reserved_at_ms")
EligibleUntilMsColumn = postgres.IntegerColumn("eligible_until_ms")
RegisteredAtMsColumn = postgres.IntegerColumn("registered_at_ms")
allColumns = postgres.ColumnList{CanonicalKeyColumn, GameIDColumn, HolderUserIDColumn, RaceNameColumn, BindingKindColumn, SourceGameIDColumn, ReservedAtMsColumn, EligibleUntilMsColumn, RegisteredAtMsColumn}
mutableColumns = postgres.ColumnList{HolderUserIDColumn, RaceNameColumn, BindingKindColumn, SourceGameIDColumn, ReservedAtMsColumn, EligibleUntilMsColumn, RegisteredAtMsColumn}
defaultColumns = postgres.ColumnList{GameIDColumn, SourceGameIDColumn, ReservedAtMsColumn}
)
return raceNamesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
CanonicalKey: CanonicalKeyColumn,
GameID: GameIDColumn,
HolderUserID: HolderUserIDColumn,
RaceName: RaceNameColumn,
BindingKind: BindingKindColumn,
SourceGameID: SourceGameIDColumn,
ReservedAtMs: ReservedAtMsColumn,
EligibleUntilMs: EligibleUntilMsColumn,
RegisteredAtMs: RegisteredAtMsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
Applications = Applications.FromSchema(schema)
Games = Games.FromSchema(schema)
GooseDbVersion = GooseDbVersion.FromSchema(schema)
Invites = Invites.FromSchema(schema)
Memberships = Memberships.FromSchema(schema)
RaceNames = RaceNames.FromSchema(schema)
}
@@ -0,0 +1,346 @@
// Package membershipstore implements the PostgreSQL-backed adapter for
// `ports.MembershipStore`.
//
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
// membership records.
package membershipstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed membership store instance.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Lobby membership records in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed membership store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres membership store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres membership store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// membershipSelectColumns is the canonical SELECT list for the memberships
// table, matching scanMembership's column order.
var membershipSelectColumns = pg.ColumnList{
pgtable.Memberships.MembershipID,
pgtable.Memberships.GameID,
pgtable.Memberships.UserID,
pgtable.Memberships.RaceName,
pgtable.Memberships.CanonicalKey,
pgtable.Memberships.Status,
pgtable.Memberships.JoinedAt,
pgtable.Memberships.RemovedAt,
}
// Save persists a new active membership record. Save is create-only; a
// second save against the same membership id maps the unique-violation to
// membership.ErrConflict.
func (store *Store) Save(ctx context.Context, record membership.Membership) error {
if store == nil || store.db == nil {
return errors.New("save membership: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("save membership: %w", err)
}
if record.Status != membership.StatusActive {
return fmt.Errorf(
"save membership: status must be %q, got %q",
membership.StatusActive, record.Status,
)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save membership", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.Memberships.INSERT(
pgtable.Memberships.MembershipID,
pgtable.Memberships.GameID,
pgtable.Memberships.UserID,
pgtable.Memberships.RaceName,
pgtable.Memberships.CanonicalKey,
pgtable.Memberships.Status,
pgtable.Memberships.JoinedAt,
pgtable.Memberships.RemovedAt,
).VALUES(
record.MembershipID.String(),
record.GameID.String(),
record.UserID,
record.RaceName,
record.CanonicalKey,
string(record.Status),
record.JoinedAt.UTC(),
sqlx.NullableTimePtr(record.RemovedAt),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("save membership: %w", membership.ErrConflict)
}
return fmt.Errorf("save membership: %w", err)
}
return nil
}
// Get returns the record identified by membershipID.
func (store *Store) Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error) {
if store == nil || store.db == nil {
return membership.Membership{}, errors.New("get membership: nil store")
}
if err := membershipID.Validate(); err != nil {
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get membership", store.operationTimeout)
if err != nil {
return membership.Membership{}, err
}
defer cancel()
stmt := pg.SELECT(membershipSelectColumns).
FROM(pgtable.Memberships).
WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(membershipID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanMembership(row)
if sqlx.IsNoRows(err) {
return membership.Membership{}, membership.ErrNotFound
}
if err != nil {
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
}
return record, nil
}
// GetByGame returns every membership attached to gameID.
func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error) {
if store == nil || store.db == nil {
return nil, errors.New("get memberships by game: nil store")
}
if err := gameID.Validate(); err != nil {
return nil, fmt.Errorf("get memberships by game: %w", err)
}
stmt := pg.SELECT(membershipSelectColumns).
FROM(pgtable.Memberships).
WHERE(pgtable.Memberships.GameID.EQ(pg.String(gameID.String()))).
ORDER_BY(pgtable.Memberships.JoinedAt.ASC(), pgtable.Memberships.MembershipID.ASC())
return store.queryList(ctx, "get memberships by game", stmt)
}
// GetByUser returns every membership held by userID.
func (store *Store) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) {
if store == nil || store.db == nil {
return nil, errors.New("get memberships by user: nil store")
}
trimmed := strings.TrimSpace(userID)
if trimmed == "" {
return nil, fmt.Errorf("get memberships by user: user id must not be empty")
}
stmt := pg.SELECT(membershipSelectColumns).
FROM(pgtable.Memberships).
WHERE(pgtable.Memberships.UserID.EQ(pg.String(trimmed))).
ORDER_BY(pgtable.Memberships.JoinedAt.ASC(), pgtable.Memberships.MembershipID.ASC())
return store.queryList(ctx, "get memberships by user", stmt)
}
func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]membership.Membership, error) {
operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
defer rows.Close()
records := make([]membership.Membership, 0)
for rows.Next() {
record, err := scanMembership(rows)
if err != nil {
return nil, fmt.Errorf("%s: scan: %w", operation, err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
if len(records) == 0 {
return nil, nil
}
return records, nil
}
// UpdateStatus applies one status transition with compare-and-swap on the
// current status column. RemovedAt is set to input.At when transitioning out
// of active.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error {
if store == nil || store.db == nil {
return errors.New("update membership status: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update membership status: %w", err)
}
if err := membership.Transition(input.ExpectedFrom, input.To); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update membership status", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
at := input.At.UTC()
stmt := pgtable.Memberships.UPDATE(pgtable.Memberships.Status, pgtable.Memberships.RemovedAt).
SET(string(input.To), at).
WHERE(pg.AND(
pgtable.Memberships.MembershipID.EQ(pg.String(input.MembershipID.String())),
pgtable.Memberships.Status.EQ(pg.String(string(input.ExpectedFrom))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update membership status: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update membership status: rows affected: %w", err)
}
if affected == 0 {
probe := pg.SELECT(pgtable.Memberships.Status).
FROM(pgtable.Memberships).
WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(input.MembershipID.String())))
probeQuery, probeArgs := probe.Sql()
var current string
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
if err := row.Scan(&current); err != nil {
if sqlx.IsNoRows(err) {
return membership.ErrNotFound
}
return fmt.Errorf("update membership status: probe: %w", err)
}
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
}
return nil
}
// Delete removes the membership record identified by membershipID. The
// pre-start removemember path uses Delete; the post-start path uses
// UpdateStatus(active → removed).
func (store *Store) Delete(ctx context.Context, membershipID common.MembershipID) error {
if store == nil || store.db == nil {
return errors.New("delete membership: nil store")
}
if err := membershipID.Validate(); err != nil {
return fmt.Errorf("delete membership: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete membership", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.Memberships.DELETE().
WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(membershipID.String())))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("delete membership: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("delete membership: rows affected: %w", err)
}
if affected == 0 {
return membership.ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanMembership(rs rowScanner) (membership.Membership, error) {
var (
membershipID string
gameID string
userID string
raceName string
canonicalKey string
status string
joinedAt time.Time
removedAt sql.NullTime
)
if err := rs.Scan(
&membershipID,
&gameID,
&userID,
&raceName,
&canonicalKey,
&status,
&joinedAt,
&removedAt,
); err != nil {
return membership.Membership{}, err
}
return membership.Membership{
MembershipID: common.MembershipID(membershipID),
GameID: common.GameID(gameID),
UserID: userID,
RaceName: raceName,
CanonicalKey: canonicalKey,
Status: membership.Status(status),
JoinedAt: joinedAt.UTC(),
RemovedAt: sqlx.TimePtrFromNullable(removedAt),
}, nil
}
// Ensure Store satisfies the ports.MembershipStore interface at compile
// time.
var _ ports.MembershipStore = (*Store)(nil)
@@ -0,0 +1,213 @@
package membershipstore_test
import (
"context"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/gamestore"
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
"galaxy/lobby/internal/adapters/postgres/membershipstore"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStores(t *testing.T) (*gamestore.Store, *membershipstore.Store) {
t.Helper()
pgtest.TruncateAll(t)
gs, err := gamestore.New(gamestore.Config{
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
ms, err := membershipstore.New(membershipstore.Config{
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return gs, ms
}
func seedGame(t *testing.T, gs *gamestore.Store, id string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
g, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "G " + id,
GameType: game.GameTypePublic,
MinPlayers: 2,
MaxPlayers: 8,
StartGapHours: 12,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.0.0",
Now: now,
})
require.NoError(t, err)
require.NoError(t, gs.Save(context.Background(), g))
return g
}
func newMembership(t *testing.T, id, gameID, userID, race, canon string) membership.Membership {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
rec, err := membership.New(membership.NewMembershipInput{
MembershipID: common.MembershipID(id),
GameID: common.GameID(gameID),
UserID: userID,
RaceName: race,
CanonicalKey: canon,
Now: now,
})
require.NoError(t, err)
return rec
}
func TestSaveAndGet(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot Alpha", "pilot-alpha")
require.NoError(t, ms.Save(ctx, rec))
got, err := ms.Get(ctx, rec.MembershipID)
require.NoError(t, err)
assert.Equal(t, rec.MembershipID, got.MembershipID)
assert.Equal(t, "Pilot Alpha", got.RaceName)
assert.Equal(t, "pilot-alpha", got.CanonicalKey)
assert.Equal(t, membership.StatusActive, got.Status)
assert.Nil(t, got.RemovedAt)
}
func TestSaveRejectsNonActive(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
rec.Status = membership.StatusRemoved
require.Error(t, ms.Save(ctx, rec))
}
func TestSaveDuplicateReturnsConflict(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
require.NoError(t, ms.Save(ctx, rec))
err := ms.Save(ctx, rec)
require.ErrorIs(t, err, membership.ErrConflict)
}
func TestUpdateStatusToRemovedSetsRemovedAt(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
require.NoError(t, ms.Save(ctx, rec))
at := rec.JoinedAt.Add(time.Minute)
require.NoError(t, ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
MembershipID: rec.MembershipID,
ExpectedFrom: membership.StatusActive,
To: membership.StatusRemoved,
At: at,
}))
got, err := ms.Get(ctx, rec.MembershipID)
require.NoError(t, err)
assert.Equal(t, membership.StatusRemoved, got.Status)
require.NotNil(t, got.RemovedAt)
assert.True(t, got.RemovedAt.Equal(at))
}
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
require.NoError(t, ms.Save(ctx, rec))
// Move the row out of `active` first; the next attempt's
// `WHERE status = 'active'` then fails on persistence even though
// (active → blocked) is itself a valid transition in the domain table.
require.NoError(t, ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
MembershipID: rec.MembershipID,
ExpectedFrom: membership.StatusActive,
To: membership.StatusRemoved,
At: rec.JoinedAt.Add(time.Minute),
}))
err := ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
MembershipID: rec.MembershipID,
ExpectedFrom: membership.StatusActive,
To: membership.StatusBlocked,
At: rec.JoinedAt.Add(2 * time.Minute),
})
require.ErrorIs(t, err, membership.ErrConflict)
}
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
_, ms := newStores(t)
err := ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
MembershipID: common.MembershipID("membership-missing"),
ExpectedFrom: membership.StatusActive,
To: membership.StatusRemoved,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, membership.ErrNotFound)
}
func TestDeleteRemovesRecord(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
require.NoError(t, ms.Save(ctx, rec))
require.NoError(t, ms.Delete(ctx, rec.MembershipID))
_, err := ms.Get(ctx, rec.MembershipID)
require.ErrorIs(t, err, membership.ErrNotFound)
}
func TestDeleteReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
_, ms := newStores(t)
err := ms.Delete(ctx, common.MembershipID("membership-missing"))
require.ErrorIs(t, err, membership.ErrNotFound)
}
func TestGetByGameAndUser(t *testing.T) {
ctx := context.Background()
gs, ms := newStores(t)
seedGame(t, gs, "game-001")
seedGame(t, gs, "game-002")
require.NoError(t, ms.Save(ctx, newMembership(t, "membership-001", "game-001", "user-a", "P-a", "p-a")))
require.NoError(t, ms.Save(ctx, newMembership(t, "membership-002", "game-001", "user-b", "P-b", "p-b")))
require.NoError(t, ms.Save(ctx, newMembership(t, "membership-003", "game-002", "user-a", "P-a2", "p-a2")))
g1, err := ms.GetByGame(ctx, common.GameID("game-001"))
require.NoError(t, err)
assert.Len(t, g1, 2)
userA, err := ms.GetByUser(ctx, "user-a")
require.NoError(t, err)
assert.Len(t, userA, 2)
}
func TestGetMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
_, ms := newStores(t)
_, err := ms.Get(ctx, common.MembershipID("membership-missing"))
require.ErrorIs(t, err, membership.ErrNotFound)
}
@@ -0,0 +1,169 @@
-- +goose Up
-- Initial Game Lobby PostgreSQL schema.
--
-- Five tables cover the durable surface of the service:
-- * games, applications, invites, memberships — the four core
-- enrollment entities;
-- * race_names — the Race Name Directory, holding the registered /
-- reservation / pending_registration bindings keyed by canonical key.
--
-- Schema and the matching `lobbyservice` role are provisioned outside
-- this script (in tests via
-- integration/internal/harness/postgres_container.go::EnsureRoleAndSchema;
-- in production via an ops init script). This migration runs as the
-- schema owner with `search_path=lobby` and only contains DDL for the
-- service-owned tables and indexes.
-- games holds one durable record per platform game session. The status +
-- created_at index serves the listing/scheduler queries that previously
-- read `lobby:games_by_status:*`. The partial owner index serves the
-- per-owner listings used by user-lifecycle cascade and "my games"
-- listings; public games carry an empty owner_user_id and never enter
-- the index.
CREATE TABLE games (
game_id text PRIMARY KEY,
game_name text NOT NULL,
description text NOT NULL DEFAULT '',
game_type text NOT NULL,
owner_user_id text NOT NULL DEFAULT '',
status text NOT NULL,
min_players integer NOT NULL,
max_players integer NOT NULL,
start_gap_hours integer NOT NULL,
start_gap_players integer NOT NULL,
enrollment_ends_at timestamptz NOT NULL,
turn_schedule text NOT NULL,
target_engine_version text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
started_at timestamptz,
finished_at timestamptz,
runtime_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
runtime_binding jsonb
);
CREATE INDEX games_status_created_idx
ON games (status, created_at DESC, game_id DESC);
CREATE INDEX games_owner_idx
ON games (owner_user_id) WHERE game_type = 'private';
-- applications carries one row per public-game enrollment request. The
-- partial UNIQUE on (applicant_user_id, game_id) WHERE status <> 'rejected'
-- replaces the Redis lookup key `lobby:user_game_application:*:*` and
-- enforces the single-active constraint at the database level. Rejected
-- applications are kept (one applicant may produce multiple rejected rows
-- before submitting a successful one).
CREATE TABLE applications (
application_id text PRIMARY KEY,
game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
applicant_user_id text NOT NULL,
race_name text NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
decided_at timestamptz
);
CREATE INDEX applications_game_idx ON applications (game_id);
CREATE INDEX applications_user_idx ON applications (applicant_user_id);
CREATE UNIQUE INDEX applications_active_per_user_game_uidx
ON applications (applicant_user_id, game_id)
WHERE status <> 'rejected';
-- invites carries one row per private-game invitation. race_name is empty
-- until the invite transitions to redeemed. The (status, expires_at) index
-- serves the enrollment-automation expiration sweep; the per-game,
-- per-invitee, and per-inviter indexes serve listing queries from the
-- service layer.
CREATE TABLE invites (
invite_id text PRIMARY KEY,
game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
inviter_user_id text NOT NULL,
invitee_user_id text NOT NULL,
race_name text NOT NULL DEFAULT '',
status text NOT NULL,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
decided_at timestamptz
);
CREATE INDEX invites_game_idx ON invites (game_id);
CREATE INDEX invites_invitee_idx ON invites (invitee_user_id);
CREATE INDEX invites_inviter_idx ON invites (inviter_user_id);
CREATE INDEX invites_status_expires_idx ON invites (status, expires_at);
-- memberships carries one row per platform roster entry. Both race_name
-- (original casing) and canonical_key are stored explicitly because
-- downstream readers (capability evaluation, cascade release) consume the
-- canonical form without re-deriving it from race_name. Race-name
-- uniqueness is enforced by the Race Name Directory (the race_names
-- table below) — this table intentionally has no unique constraint on
-- canonical_key.
CREATE TABLE memberships (
membership_id text PRIMARY KEY,
game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
user_id text NOT NULL,
race_name text NOT NULL,
canonical_key text NOT NULL,
status text NOT NULL,
joined_at timestamptz NOT NULL,
removed_at timestamptz
);
CREATE INDEX memberships_game_idx ON memberships (game_id);
CREATE INDEX memberships_user_idx ON memberships (user_id);
-- race_names is the durable Race Name Directory store. One row covers one
-- of three bindings on a canonical key: a registered name (one per
-- canonical_key, immutable holder), a per-game reservation, or a
-- pending_registration that is waiting on lobby.race_name.register inside
-- the eligible_until_ms window. The composite primary key (canonical_key,
-- game_id) lets the same user hold reservations for the same race name
-- across multiple active games concurrently, matching the behaviour the
-- shared port test suite (lobby/internal/ports/racenamedirtest) covers.
-- Registered rows store game_id = '' and keep the source game in
-- source_game_id so the per-canonical uniqueness rule expresses cleanly
-- as a partial UNIQUE index. Cross-user uniqueness on canonical_key is
-- enforced at write time inside transactions guarded by
-- pg_advisory_xact_lock(hashtextextended(canonical_key, 0)).
CREATE TABLE race_names (
canonical_key text NOT NULL,
game_id text NOT NULL DEFAULT '',
holder_user_id text NOT NULL,
race_name text NOT NULL,
binding_kind text NOT NULL,
source_game_id text NOT NULL DEFAULT '',
reserved_at_ms bigint NOT NULL DEFAULT 0,
eligible_until_ms bigint,
registered_at_ms bigint,
PRIMARY KEY (canonical_key, game_id),
CONSTRAINT race_names_binding_kind_chk
CHECK (binding_kind IN ('registered', 'reservation', 'pending_registration'))
);
-- Exactly one registered binding per canonical_key. Reservations and
-- pending_registration entries are differentiated by game_id within the
-- primary key.
CREATE UNIQUE INDEX race_names_registered_uidx
ON race_names (canonical_key)
WHERE binding_kind = 'registered';
-- Per-user listings used by ListRegistered / ListReservations /
-- ListPendingRegistrations.
CREATE INDEX race_names_holder_idx
ON race_names (holder_user_id, binding_kind);
-- Pending-registration expiration scanner reads only the pending subset
-- ordered by eligible_until_ms.
CREATE INDEX race_names_pending_eligible_idx
ON race_names (eligible_until_ms)
WHERE binding_kind = 'pending_registration';
-- +goose Down
DROP TABLE IF EXISTS race_names;
DROP TABLE IF EXISTS memberships;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS applications;
DROP TABLE IF EXISTS games;
@@ -0,0 +1,19 @@
// Package migrations exposes the embedded goose migration files used by
// Game Lobby Service to provision its `lobby` schema in PostgreSQL.
//
// The embedded filesystem is consumed by `pkg/postgres.RunMigrations` during
// lobby-service startup and by `cmd/jetgen` when regenerating the
// `internal/adapters/postgres/jet/` code against a transient PostgreSQL
// instance.
package migrations
import "embed"
//go:embed *.sql
var fs embed.FS
// FS returns the embedded filesystem containing every numbered goose
// migration shipped with Game Lobby Service.
func FS() embed.FS {
return fs
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
package racenamedir_test
import (
"context"
"database/sql"
"strconv"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
"galaxy/lobby/internal/adapters/postgres/racenamedir"
"galaxy/lobby/internal/domain/racename"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/ports/racenamedirtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMain wires the per-package PostgreSQL container shared by every
// store test in this module.
func TestMain(m *testing.M) { pgtest.RunMain(m) }
// newDirectory builds one Race Name Directory adapter against a freshly
// truncated lobby schema. now selects between the deterministic clock the
// shared suite supplies and the default time.Now.
func newDirectory(t *testing.T, now func() time.Time) *racenamedir.Directory {
t.Helper()
pgtest.TruncateAll(t)
policy, err := racename.NewPolicy()
require.NoError(t, err)
cfg := racenamedir.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
Policy: policy,
}
if now != nil {
cfg.Clock = now
}
directory, err := racenamedir.New(cfg)
require.NoError(t, err)
return directory
}
// TestRaceNameDirectoryContract runs the shared behavioural suite that
// every ports.RaceNameDirectory implementation must pass.
func TestRaceNameDirectoryContract(t *testing.T) {
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
return newDirectory(t, now)
})
}
func TestNewRejectsNilDB(t *testing.T) {
policy, err := racename.NewPolicy()
require.NoError(t, err)
_, err = racenamedir.New(racenamedir.Config{
OperationTimeout: pgtest.OperationTimeout,
Policy: policy,
})
require.Error(t, err)
}
func TestNewRejectsNilPolicy(t *testing.T) {
_, err := racenamedir.New(racenamedir.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.Error(t, err)
}
func TestNewRejectsNonPositiveTimeout(t *testing.T) {
policy, err := racename.NewPolicy()
require.NoError(t, err)
_, err = racenamedir.New(racenamedir.Config{
DB: pgtest.Ensure(t).Pool(),
Policy: policy,
})
require.Error(t, err)
}
// TestRegisteredRowShape validates the on-disk shape of a registered
// binding so future schema migrations have an explicit anchor.
func TestRegisteredRowShape(t *testing.T) {
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
directory := newDirectory(t, func() time.Time { return now })
ctx := context.Background()
const (
gameID = "game-shape-1"
userID = "user-shape-1"
raceName = "PilotNova"
)
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, now.Add(time.Hour)))
require.NoError(t, directory.Register(ctx, gameID, userID, raceName))
pool := pgtest.Ensure(t).Pool()
canonical, err := directory.Canonicalize(raceName)
require.NoError(t, err)
row := pool.QueryRowContext(ctx, `
SELECT canonical_key, game_id, holder_user_id, race_name, binding_kind,
source_game_id, reserved_at_ms, eligible_until_ms, registered_at_ms
FROM race_names
WHERE canonical_key = $1
`, canonical)
var (
canonicalKey string
storedGameID string
holderUserID string
raceNameCol string
bindingKind string
sourceGameID string
reservedAtMs int64
eligibleAtMs sql.NullInt64
registeredAtMs sql.NullInt64
)
require.NoError(t, row.Scan(
&canonicalKey,
&storedGameID,
&holderUserID,
&raceNameCol,
&bindingKind,
&sourceGameID,
&reservedAtMs,
&eligibleAtMs,
&registeredAtMs,
))
assert.Equal(t, canonical, canonicalKey)
assert.Equal(t, "", storedGameID, "registered rows store game_id = ''")
assert.Equal(t, userID, holderUserID)
assert.Equal(t, raceName, raceNameCol)
assert.Equal(t, ports.KindRegistered, bindingKind)
assert.Equal(t, gameID, sourceGameID)
assert.True(t, registeredAtMs.Valid)
assert.Equal(t, now.UTC().UnixMilli(), registeredAtMs.Int64)
assert.False(t, eligibleAtMs.Valid, "registered rows null out eligible_until_ms")
assert.Equal(t, now.UTC().UnixMilli(), reservedAtMs, "reserved_at_ms is preserved across promote+register")
}
// TestRegisteredPartialUniqueIndex confirms that a second user cannot
// register the same canonical key, even when they own a separate
// reservation row at a different (canonical_key, game_id) PK.
func TestRegisteredPartialUniqueIndex(t *testing.T) {
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
directory := newDirectory(t, func() time.Time { return now })
ctx := context.Background()
const (
raceName = "PilotNova"
gameA = "game-unique-a"
userA = "user-unique-a"
userB = "user-unique-b"
)
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceName))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceName, now.Add(time.Hour)))
require.NoError(t, directory.Register(ctx, gameA, userA, raceName))
err := directory.Reserve(ctx, gameA, userB, raceName)
require.ErrorIs(t, err, ports.ErrNameTaken)
}
// TestExpirePendingRegistrationsBatched seeds two pending entries with
// distinct canonical keys and asserts both are released by a single pass
// even when the worker iterates via separate advisory locks.
func TestExpirePendingRegistrationsBatched(t *testing.T) {
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
directory := newDirectory(t, func() time.Time { return now })
ctx := context.Background()
for index := range 3 {
gameID := "game-batch-" + strconv.Itoa(index)
userID := "user-batch-" + strconv.Itoa(index)
raceName := "PilotBatch" + strconv.Itoa(index)
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, now.Add(time.Hour)))
}
expired, err := directory.ExpirePendingRegistrations(ctx, now.Add(2*time.Hour))
require.NoError(t, err)
require.Len(t, expired, 3)
expired, err = directory.ExpirePendingRegistrations(ctx, now.Add(2*time.Hour))
require.NoError(t, err)
assert.Empty(t, expired, "second pass releases nothing")
}