[Article] Full-stack ReScript

Hi there!

In a spasm of graphomania, I’ve written a post about using ReScript for back-end development in a couple of projects. The article is just an outline, but I hope it can help someone: I saw a few posts here, on the forum, where people employ ReScript on the server as well.

If you’d like to read about a particular aspect/layer in more detail, please, let me know.

18 Likes

What a delicious blog. Will add it to awesome-rescript if you don’t mind.

4 Likes

Hello, could you do a Dark Theme on your site ? It’s better to read it without the light colors :grinning_face_with_smiling_eyes:

:slight_smile: I’m quite short of time to do this. But I always cross-post to dev.to (with the same title). It has a dark theme, so perhaps it’s a viable workaround.

1 Like

Like your https://fullsteak.dev/posts/rescript-json-typed-strongly too. We use Prisma, and I basically consider that our domain models, despite being so tightly coupled to the database. What’s your take here? Would you actually create an entire domain object over the Prisma models?

Context: New, simple codebase with prisma and fastify. I’d like to use rescript but man, I’ve got my complete typescript types end to end from http to db layer. Branching logic isn’t too complex yet.

I can’t remember such a problem. Yes, converting between JSON <-> DTO <-> Domain is boilerplate, but the Domain <-> DB Store is often related to re-arranging data shape to place it into relational tables efficiently. And I’ve done it for each store by hand.

Perhaps, if an app is barely a CRUD-app, this conversion becomes boilerplate too, I don’t know. It was not the case for the game. Also, a typical template for store implementations has born before introducing Jzon. So, I used bs-json to decode results from PgSQL.

Hopefully an uncensored copy/paste example of a random store implementation can give you some ideas :slight_smile: Sorry, it’s still in ReasonML, but nevertheless:

// RoomStore.rei

open SimpleTypes;

module Room = LobbyDomain.Room;
module RoomTable = LobbyDomain.RoomTable;

type t;

let make: KnexConnection.knex => t;

let readRoomTable: (t, roomId) => Promise.Option.t(RoomTable.t);
let findGlobalVacantRoomTable: t => Promise.Option.t(RoomTable.t);
let findWaitingRoomsJoinedByPlayer:
  (t, playerId) => Promise.t(array(Room.t));

let create: (t, Room.t) => Promise.t(unit);
let addPlayerToRoom: (t, playerId, roomId) => Promise.t(unit);
let removePlayerFromRoom: (t, playerId, roomId) => Promise.t(unit);
let markAsPlaying: (t, roomId) => Promise.t(unit);

// RoomStore.re

open Knex.Params.Infix; /* Brings (??) and (?|) */
open SimpleTypes;

module JD = Json.Decode;
module Room = LobbyDomain.Room;
module RoomTable = LobbyDomain.RoomTable;

type t = KnexConnection.knex;

let decodeRoom = row => {
  let roomId = RoomId(row |> JD.field("id", JD.string));
  let privacy =
    row |> JD.field("global", JD.bool) ? Room.Global : Room.Friends;
  let numberOfPlayers = row |> JD.field("number_of_players", JD.int);
  let address =
    switch (privacy) {
    | Global => Room.Address.GlobalRoom({numberOfPlayers, roomId})
    | Friends => Room.Address.FriendsRoom({numberOfPlayers, roomId})
    };

  Room.make(
    ~address,
    ~buyin=
      (row |> JD.field("buyin", JD.string))->Int.fromString->Option.getExn,
    ~tax=(row |> JD.field("tax", JD.string))->Int.fromString->Option.getExn,
    (),
  )
  ->Result.getExn;
};

let readRoom = (knex, roomId) =>
  Knex.Select.(
    make(knex)
    |> from("rooms")
    |> column("id")
    |> column("global")
    |> column("number_of_players")
    |> column("buyin")
    |> column("tax")
    |> where("waiting = TRUE")
    |> whereParam("id = ?", ??roomId)
    |> execute
  )
  |> Promise.map(Array.get(_, 0))
  |> Promise.map(Option.map(_, decodeRoom));

let readRoomPlayerIds = (knex, roomId) =>
  Knex.Select.(
    make(knex)
    |> from("rooms_players")
    |> column("player_id")
    |> whereParam("room_id = ?", ??roomId)
    |> orderBy("created_at", Ascending)
    |> execute
  )
  |> Promise.map(
       Array.map(_, row => PlayerId(row |> JD.field("player_id", JD.int))),
     );

let readRoomTable = (knex, roomId) => {
  let%Promise (maybeRoom, playerIds) =
    Promise.all2((readRoom(knex, roomId), readRoomPlayerIds(knex, roomId)));

  maybeRoom
  ->Option.map(room => RoomTable.make(room, playerIds))
  ->Promise.resolve;
};

let findGlobalVacantRoomTable = knex => {
  let%Promise maybeRoomId =
    KnexX.Select.(
      make(knex)
      |> from("rooms")
      |> column("id")
      |> where("waiting = TRUE")
      |> where("global = TRUE")
      |> execute
    )
    |> Promise.map(rows => {
         rows
         ->Array.get(0)
         ->Option.map(row => RoomId(row |> JD.field("id", JD.string)))
       });

  switch (maybeRoomId) {
  | Some(roomId) => readRoomTable(knex, roomId)
  | None => None->Promise.resolve
  };
};

let findWaitingRoomsJoinedByPlayer = (knex, playerId) =>
  Knex.Select.(
    make(knex)
    |> from("rooms")
    |> innerJoin("rooms_players", "rooms.id", "=", "rooms_players.room_id")
    |> column("rooms.id")
    |> column("rooms.global")
    |> column("rooms.number_of_players")
    |> column("rooms.buyin")
    |> column("rooms.tax")
    |> where("rooms.waiting = TRUE")
    |> whereParam("rooms_players.player_id = ?", ??playerId)
    |> execute
  )
  |> Promise.map(Array.map(_, decodeRoom));

let create = (knex, room) => {
  // For now the prize amounts are extraneous but it might be changed in future
  // if some challenges with guaranted prize over the buyin would be introduced
  let prizeFor = place =>
    room->Room.prizeAmounts->Array.get(place - 1)->Option.getWithDefault(0);

  Knex.Insert.(
    make(knex)
    |> into("rooms")
    |> set("id", room->Room.id)
    |> set("global", room->Room.privacy == Global)
    |> set("number_of_players", room->Room.numberOfPlayers)
    |> set("buyin", room->Room.buyin)
    |> set("tax", room->Room.tax)
    |> set("prize_for_1st", prizeFor(1))
    |> set("prize_for_2nd", prizeFor(2))
    |> set("prize_for_3rd", prizeFor(3))
    |> set("prize_for_4th", prizeFor(4))
    |> execute
  )
  |> Promise.erase;
};

let addPlayerToRoom = (knex, playerId, roomId) =>
  Knex.Insert.(
    make(knex)
    |> into("rooms_players")
    |> set("player_id", playerId)
    |> set("room_id", roomId)
    |> set("seat", 0)  /* TODO: remove this column, it is extraneous */
    |> execute
  )
  |> Promise.erase;

let removePlayerFromRoom = (knex, playerId, roomId) =>
  Knex.Delete.
    /* TODO: do not delete, but set `left_at` for analytics */
    (
      make("rooms_players", knex)
      |> whereParam("player_id = ?", ??playerId)
      |> whereParam("room_id = ?", ??roomId)
      |> execute
    )
  |> Promise.erase;

let markAsPlaying = (knex, roomId) =>
  Knex.Update.(
    make("rooms", knex)
    |> set("waiting", false)
    |> whereParam("id = ?", ??roomId)
    |> execute
  )
  |> Promise.erase;

let make = knex => knex;
1 Like