Introduction

This is part of an ongoing series about the Rescript programming language. I’m taking notes as I build a toy “Recipe Book” app to explore the Rescript ecosystem.

As a continuation of my previous article on building a graphql server in Rescript. Before we can actually create the graphql endpoints for our server, I discovered we need to refactor the backend code quite a bit. The data store in the frontend has evolved from what I originally planned, and the backend hasn’t kept up.

This is a terrific opportunity to see how Rescript’s lightning fast compile times make it easy to quickly and confidently make sweeping code changes.

Patreon

This series takes a lot of time to write and maintain. I would definitely have given it up by now if not for the motivation provided by my patrons. If you would like to continue reading content like this, I’ve set up a Patreon account. I thank you for your support. I am not expecting this to pay for my time investment, but it means so much to know people are placing value on my content.

As a bonus for patrons, articles will be published there at least two weeks before they make it to my public blog. In addition, if enough interest is shown through Patreon, I will consider writing a book on Rescript.

While I originally set up the Patreon for individuals to support me, I’d like to point out that if your company is invested in the Rescript ecosystem, my work is probably helping you recruit and onboard new hires. Supporting my efforts makes the ecosystem stronger.

Other ways to show support include sharing the articles on social media, commenting, or a quick thank you on the Rescript forum.

With over a dozen articles and counting, I’ve created a table of contents listing all the articles in this series in reading order.

Table of Contents

Let’s begin

We’ll be extending the rescript-express-recipes repo I’ve used in a couple previous articles.

If you haven’t been following along with these articles, you can git checkout the graphql branch.

If you want to keep up with the commits for this article, they are in the refactor branch. Each commit roughly maps to a section in this article.

Note: This article in particular contains a lot of modification instructions that may be easier to follow in github’s diff format than in my inline code examples.

Update store for rxdb sync

Our end goal is to supply mutations for the frontend app in the rescript-offline repository. As a reminder, that project uses rxdb with two collections defined as follows:

type id = string
type title = string
type ingredients = string
type instructions = string
type tag = string

type recipe = {
  id: id,
  title: title,
  ingredients: ingredients,
  instructions: instructions,
  tags: array<tag>,
}

type taggedRecipes = {
  tag: tag,
  recipes: array<id>,
}

Our server code, on the other hand, currently defines this state:

type id = int

type recipe = {
  id: id,
  title: string,
  ingredients: string,
  instructions: string,
  tags: array<string>,
}

type state = {
  nextId: id,
  recipes: Map.Int.t<recipe>,
  tags: Map.String.t<array<int>>,
}

This is the kind of inconsistency that happens when your frontend and backend developers don’t communicate effectively! (If you think I should have communicated better with myself, you don’t know me very well!) The main functional difference is that the id in the offline app is a string, but our sample graphql server is using an int. But I think it’s worth porting the additional type safety into the backend as well.

So go ahead and merge the above two sequences of code in Store.res. It should come out as follows:

type id = string
type title = string
type ingredients = string
type instructions = string
type tag = string

type recipe = {
  id: id,
  title: title,
  ingredients: ingredients,
  instructions: instructions,
  tags: array<tag>,
}

type state = {
  recipes: Map.String.t<recipe>,
  tags: Map.String.t<array<id>>,
}

let initialState: state = {
  recipes: Map.String.empty,
  tags: Map.String.empty,
}

String ids

The ids being strings instead of ints means that I had to remove nextId. Instead, the offline app uses a uuid. The above change breaks the build in addRecipe because it’s still trying to create an integer. For symmetry with the offline app, add a dependency on UUID:

npm install --save uuid

Then add a binding to the library at the top of Store.res:

@module("uuid") external uuid: unit => string = "v4"

I just copy-pasted this binding from the other project. Theoretically, I could extract this one line of code to a package. In fact, there’s also a reason-uuid library that I could depend on. But Rescript bindings are so easy that I’ve been finding it far more productive to write them on demand than depend on external packages.

Now we just need to follow the Rescript errors. Gotta love those lightning fast compile times. It only took me a minute to identify and fix the first set of changes:

  • Change let id = state.nextid to let id = uuid()
  • Remove the nextId: state.nextId + 1 in the reducer itself
  • Change the Map.Int references to Map.String in:
    • The new state setting in addRecipe
    • The recipeOption lookup in addTag
    • The recipe state update in addTag
  • Change all the type definitions of recipeid from int to id. (A find and replace on int will suffice if you don’t want to track them down)
  • While I was at it, I replaced all the string definitions with the more concrete title, ingredients, instructions, and tag types as appropriate.
  • Remove the nextId: from the state update in addTag

Unfortunately, I got slowed up with the next build error. It is in Index.res, which needs to know what the new ID is so it can return it. The solution is to modify the AddRecipe action and associated addRecipe reducer to accept the id passed in as an argument instead of generating a new one:

type action =
  | AddRecipe({id: id, title: title, ingredients: ingredients, instructions: instructions})
  | AddTag({recipeId: id, tag: tag})

let addRecipe = (
  state: state,
  id: id,
  title: title,
  ingredients: ingredients,
  instructions: instructions,
) => {
  {
    recipes: state.recipes->Map.String.set(
      id,
      {
        id: id,
        title: title,
        ingredients: ingredients,
        instructions: instructions,
        tags: [],
      },
    ),
    tags: state.tags,
  }
}

You’ll also need to update the reducer to include the id in both the AddRecipe pattern match and the addRecipe function call:

let reducer = (state: state, action: action) => {
  switch action {
  | AddRecipe({id, title, ingredients, instructions}) =>
    addRecipe(state, id, title, ingredients, instructions)
  | AddTag({recipeId, tag}) => addTag(state, recipeId, tag)
  }
}

Now we can update the addRecipe endpoint in Index.res to generate its own uuid before returning it as a json string instead of a number:

    switch jsonFields {
    | Some(Some(title), Some(ingredients), Some(instructions)) => {
        open Store.Reducer
        let id = Store.uuid()
        dispatch(
          AddRecipe({id: id, title: title, ingredients: ingredients, instructions: instructions}),
        )
        jsonResponse->Js.Dict.set("id", id->Js.Json.string)
      }

addTagToRecipe also needs to be updated to accept a string instead of an int. Both the number decoder (inside the flatMap) and the Map.Int need to be changed to strings:

jsonBody
->Js.Dict.get("recipeId")
->Option.flatMap(Js.Json.decodeString)
->Option.flatMap(id => getState().recipes->Map.String.get(id)),
jsonBody->Js.Dict.get("tag")->Option.flatMap(Js.Json.decodeString),

The /recipes/:id endpoint requires similar changes:

  • Remove the flatMap that converts the id string to an int
  • Look up the id using Map.String.get instead of Map.Int.get
  • Set the id on the response using a Js.Json.string instead of Js.Json.number

After completing all of the above, the whole endpoint looks like this:

App.get(
  app,
  ~path="/recipes/:id",
  Middleware.from((_next, req, res) => {
    open Belt
    let jsonResponse = Js.Dict.empty()
    let state = Store.Reducer.getState()
    let recipeOption =
      req
      ->Request.params
      ->Js.Dict.get("id")
      ->Option.flatMap(Js.Json.decodeString)
      ->Option.flatMap(id => state.recipes->Map.String.get(id))
    switch recipeOption {
    | None => jsonResponse->Js.Dict.set("error", "unable to find that recipe"->Js.Json.string)
    | Some(recipe) => {
        jsonResponse->Js.Dict.set("id", recipe.id->Js.Json.string)
        jsonResponse->Js.Dict.set("title", recipe.title->Js.Json.string)
        jsonResponse->Js.Dict.set("ingredients", recipe.ingredients->Js.Json.string)
        jsonResponse->Js.Dict.set("instructions", recipe.instructions->Js.Json.string)
        jsonResponse->Js.Dict.set("tags", recipe.tags->Js.Json.stringArray)
      }
    }
    res->Response.sendJson(jsonResponse->Js.Json.object_)
  }),
)

I won’t show the code, but you’ll have to do the same thing in the /tags/:tag endpoint. The compiler should help you out, but if you get stuck, refer back to the git repo.

Update model to match RxDB requirements

That was such a diversion, that we need to take a moment to remember our goal: to make a graphql endpoint to work with RxDB sync.

Looking at the RxDB docs for replication, we can see there are a couple more data model changes needed to make our recipes and tags collections need two more fields: updatedAt and deleted.

Let’s start by adding them to the recipe model and leave the more invasive tags refactor for a little later. First, add the fields to the type:

type recipe = {
  id: id,
  title: title,
  ingredients: ingredients,
  instructions: instructions,
  tags: array<tag>,
  updatedAt: float,
  deleted: bool,
}

This leads to just one compiler error that is easily fixed. We can set default values on the addRecipe reducer so we don’t have to change the parameters:

recipes: state.recipes->Map.String.set(
  id,
  {
    id: id,
    title: title,
    ingredients: ingredients,
    instructions: instructions,
    tags: [],
    updatedAt: Js.Date.now(),
    deleted: false,
  },
),

Changing the tag model

The current tag model is just a map of tag name to array of string recipe ids. We need to change this to include the updatedAt and deleted flags. To do this we can introduce a new taggedRecipes type similar to the one used in the offline frontend:

type taggedRecipes = {
  tag: tag,
  recipes: array<id>,
  updatedAt: float,
  deleted: bool
}

Then the state type needs to be changed to a Map.String.t<taggedRecipes>:

type taggedRecipes = {
  tag: tag,
  recipes: array<id>,
  updatedAt: float,
  deleted: boolean
}

Now updateTagsArray needs to be modified to accept an array of these objects. Because we may be creating a new one if it doesn’t exist, we also need to know the name of the tag itself, so the parameters are different. And we musn’t forget that if we are updating something, we need to change the updatedAt field! In fact, the function name isn’t really suitable anymore. Let’s change it to createOrUpdateTaggedRecipes:

let createOrUpdateTaggedRecipes = (
  taggedRecipesOption: option<taggedRecipes>,
  tag: tag,
  recipeId: id,
): option<taggedRecipes> => {
  switch taggedRecipesOption {
  | None =>
    Some({
      tag: tag,
      recipes: [recipeId],
      deleted: false,
      updatedAt: Js.Date.now(),
    })
  | Some(taggedRecipes) =>
    Some({
      ...taggedRecipes,
      updatedAt: Js.Date.now(),
      recipes: taggedRecipes.recipes->Array.concat([recipeId]),
    })
  }
}

Obviously the call to updateTagsArray also needs to be changed to accept the new tag parameter and function name:

createOrUpdateTaggedRecipes(taggedRecipesOption, tag, recipe.id)

The /tags/:tag endpoint needs to be updated to extract the recipes as well. I just modified it to pass the recipes attached to the taggedRecipes object into the pipeline. Change the Some(recipeIds) to:

    | Some(taggedRecipes) => {
        let recipes =
          taggedRecipes.recipes
          ->Array.map(id => {
            state.recipes
            ->Map.String.get(id)
            ->Option.map(recipe => {
              let dict = Js.Dict.empty()
              dict->Js.Dict.set("id", id->Js.Json.string)
              dict->Js.Dict.set("title", recipe.title->Js.Json.string)
              dict
            })
          })
          ->Array.keep(value => value->Option.isSome)
          ->Array.map(opt => opt->Option.getUnsafe->Js.Json.object_)
          ->Js.Json.array
        jsonResponse->Js.Dict.set("recipes", recipes)
      }
    }

Conclusion 🐐

This is a little more annoying to test in my REST client because the IDs are no longer deterministic. However, once I remembered to copy the ID into my requests, I discovered that everything was working fine.

I expected to actually start developing the graphql endpoints in this article, but the refactor was bigger than I expected, so I’m splitting that out for the next one.

Notice that I have still not written a line of unit tests, and yet, the major refactors I made did not break the app. It’s never ever ever safe to just rely on the compiler to guarantee that your code is running correctly. But it does allow you to develop with more confidence. With Rescript’s compiler being so incredibly fast, there is no overhead in using it as a early-phase testing mechanism.

I do have an article planned on writing unit tests. I’m definitely going to use the fastest unit test framework I can find. Rescript has taught me that execution speed is more important to development velocity than I used to believe. At one end of the spectrum, slow compiler like Rust or Java slows a programmer down. At the other end, a highly dynamic language like Python or Javascript slows a programmer down as she has to spend more time on automated testing in the early prototyping phases of the project. Rescript feels like it balances everything “just right”, at least for my development style.

The next article will get back to graphql as we hook up some endpoints to fulfill the RxDB replication protocol.