// Package playermappingstore implements the PostgreSQL-backed adapter // for `ports.PlayerMappingStore`. // // The package owns the on-disk shape of the `player_mappings` table // defined in // `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql` // and translates the schema-agnostic `ports.PlayerMappingStore` // interface declared in `internal/ports/playermappingstore.go` into // concrete go-jet/v2 statements driven by the pgx driver. // // BulkInsert ships every row in a single multi-row INSERT so the // operation is atomic — any unique-constraint violation rolls back the // whole batch and is mapped to playermapping.ErrConflict. package playermappingstore import ( "context" "database/sql" "errors" "fmt" "strings" "time" "galaxy/gamemaster/internal/adapters/postgres/internal/sqlx" pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table" "galaxy/gamemaster/internal/domain/playermapping" "galaxy/gamemaster/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // Config configures one PostgreSQL-backed player-mapping store. type Config struct { DB *sql.DB OperationTimeout time.Duration } // Store persists Game Master player mappings in PostgreSQL. type Store struct { db *sql.DB operationTimeout time.Duration } // New constructs one PostgreSQL-backed player-mapping store from cfg. func New(cfg Config) (*Store, error) { if cfg.DB == nil { return nil, errors.New("new postgres player mapping store: db must not be nil") } if cfg.OperationTimeout <= 0 { return nil, errors.New("new postgres player mapping store: operation timeout must be positive") } return &Store{ db: cfg.DB, operationTimeout: cfg.OperationTimeout, }, nil } // playerMappingSelectColumns matches scanRow's column order. var playerMappingSelectColumns = pg.ColumnList{ pgtable.PlayerMappings.GameID, pgtable.PlayerMappings.UserID, pgtable.PlayerMappings.RaceName, pgtable.PlayerMappings.EnginePlayerUUID, pgtable.PlayerMappings.CreatedAt, } // BulkInsert installs every mapping in records using a single // multi-row INSERT. Either every row is persisted or none of them is. // Any PostgreSQL unique-violation // (`(game_id, user_id)` PK or `(game_id, race_name)` UNIQUE) is mapped // to playermapping.ErrConflict. func (store *Store) BulkInsert(ctx context.Context, records []playermapping.PlayerMapping) error { if store == nil || store.db == nil { return errors.New("bulk insert player mappings: nil store") } if len(records) == 0 { return nil } for index, record := range records { if err := record.Validate(); err != nil { return fmt.Errorf("bulk insert player mappings: record %d: %w", index, err) } } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "bulk insert player mappings", store.operationTimeout) if err != nil { return err } defer cancel() stmt := pgtable.PlayerMappings.INSERT( pgtable.PlayerMappings.GameID, pgtable.PlayerMappings.UserID, pgtable.PlayerMappings.RaceName, pgtable.PlayerMappings.EnginePlayerUUID, pgtable.PlayerMappings.CreatedAt, ) for _, record := range records { stmt = stmt.VALUES( record.GameID, record.UserID, record.RaceName, record.EnginePlayerUUID, record.CreatedAt.UTC(), ) } query, args := stmt.Sql() if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil { if sqlx.IsUniqueViolation(err) { return fmt.Errorf("bulk insert player mappings: %w", playermapping.ErrConflict) } return fmt.Errorf("bulk insert player mappings: %w", err) } return nil } // Get returns the mapping identified by (gameID, userID). func (store *Store) Get(ctx context.Context, gameID, userID string) (playermapping.PlayerMapping, error) { if store == nil || store.db == nil { return playermapping.PlayerMapping{}, errors.New("get player mapping: nil store") } if strings.TrimSpace(gameID) == "" { return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: game id must not be empty") } if strings.TrimSpace(userID) == "" { return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: user id must not be empty") } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping", store.operationTimeout) if err != nil { return playermapping.PlayerMapping{}, err } defer cancel() stmt := pg.SELECT(playerMappingSelectColumns). FROM(pgtable.PlayerMappings). WHERE(pg.AND( pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)), pgtable.PlayerMappings.UserID.EQ(pg.String(userID)), )) query, args := stmt.Sql() row := store.db.QueryRowContext(operationCtx, query, args...) got, err := scanRow(row) if sqlx.IsNoRows(err) { return playermapping.PlayerMapping{}, playermapping.ErrNotFound } if err != nil { return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: %w", err) } return got, nil } // GetByRace returns the mapping identified by (gameID, raceName). func (store *Store) GetByRace(ctx context.Context, gameID, raceName string) (playermapping.PlayerMapping, error) { if store == nil || store.db == nil { return playermapping.PlayerMapping{}, errors.New("get player mapping by race: nil store") } if strings.TrimSpace(gameID) == "" { return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: game id must not be empty") } if strings.TrimSpace(raceName) == "" { return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: race name must not be empty") } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping by race", store.operationTimeout) if err != nil { return playermapping.PlayerMapping{}, err } defer cancel() stmt := pg.SELECT(playerMappingSelectColumns). FROM(pgtable.PlayerMappings). WHERE(pg.AND( pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)), pgtable.PlayerMappings.RaceName.EQ(pg.String(raceName)), )) query, args := stmt.Sql() row := store.db.QueryRowContext(operationCtx, query, args...) got, err := scanRow(row) if sqlx.IsNoRows(err) { return playermapping.PlayerMapping{}, playermapping.ErrNotFound } if err != nil { return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: %w", err) } return got, nil } // ListByGame returns every mapping owned by gameID, ordered by user_id // ascending. func (store *Store) ListByGame(ctx context.Context, gameID string) ([]playermapping.PlayerMapping, error) { if store == nil || store.db == nil { return nil, errors.New("list player mappings by game: nil store") } if strings.TrimSpace(gameID) == "" { return nil, fmt.Errorf("list player mappings by game: game id must not be empty") } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list player mappings by game", store.operationTimeout) if err != nil { return nil, err } defer cancel() stmt := pg.SELECT(playerMappingSelectColumns). FROM(pgtable.PlayerMappings). WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID))). ORDER_BY(pgtable.PlayerMappings.UserID.ASC()) query, args := stmt.Sql() rows, err := store.db.QueryContext(operationCtx, query, args...) if err != nil { return nil, fmt.Errorf("list player mappings by game: %w", err) } defer rows.Close() mappings := make([]playermapping.PlayerMapping, 0) for rows.Next() { got, err := scanRow(rows) if err != nil { return nil, fmt.Errorf("list player mappings by game: scan: %w", err) } mappings = append(mappings, got) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list player mappings by game: %w", err) } if len(mappings) == 0 { return nil, nil } return mappings, nil } // DeleteByGame removes every mapping owned by gameID. The call is // idempotent: it returns nil even when no rows were deleted. func (store *Store) DeleteByGame(ctx context.Context, gameID string) error { if store == nil || store.db == nil { return errors.New("delete player mappings by game: nil store") } if strings.TrimSpace(gameID) == "" { return fmt.Errorf("delete player mappings by game: game id must not be empty") } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete player mappings by game", store.operationTimeout) if err != nil { return err } defer cancel() stmt := pgtable.PlayerMappings.DELETE(). WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID))) query, args := stmt.Sql() if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil { return fmt.Errorf("delete player mappings by game: %w", err) } return nil } // rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared // across single-row and iterated reads. type rowScanner interface { Scan(dest ...any) error } // scanRow scans one player_mappings row from rs. func scanRow(rs rowScanner) (playermapping.PlayerMapping, error) { var ( gameID string userID string raceName string enginePlayerUUID string createdAt time.Time ) if err := rs.Scan(&gameID, &userID, &raceName, &enginePlayerUUID, &createdAt); err != nil { return playermapping.PlayerMapping{}, err } return playermapping.PlayerMapping{ GameID: gameID, UserID: userID, RaceName: raceName, EnginePlayerUUID: enginePlayerUUID, CreatedAt: createdAt.UTC(), }, nil } // Ensure Store satisfies the ports.PlayerMappingStore interface at // compile time. var _ ports.PlayerMappingStore = (*Store)(nil)