Introduction

This is part of an ongoing series about the Rescript programming language. I’ve been taking notes as I build a toy “Recipe Book” app to explore the Rescript ecosystem. The toy app has evolved into a fairly complete offline app at this point, and I’m now in the process of extending it to a syncing progressive web app.

Patreon

This series takes a lot of time and dedication to write and maintain. I started it when I was unemployed. Now that I’m working again, I’m finding I don’t have much free time. Of course, those first few weeks of onboarding are always pretty intimidating! Even so, this has gone from a full-time project to one that I am dedicating weekend and vacation time to.

If you want to help keep me invested in this writing this series, 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 are published there at least two weeks before they make it to my public blog.

For some context, my earliest articles took between 10 and 20 hours to research, write, and maintain. I’m writing somewhat shorter articles now (to maintain an approximately weekly cadance), but it’s still about 4 to 8 hours of my time each week.

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 entire 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.

Let’s begin

We’ll be extending the rescript-express-recipes repo we modified in the previous article.

If you haven’t been following along with these articles, you can git checkout the offline-sync branch to have the same starting point.

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

Remember the GraphQL Schema

The RxDB docs have a page describing what the graphql endpoints need to look like to sync a collection. The endpoints are per collection, and we need two endpoints for each collection: a query and a mutation. We already implemented the query endpoints in the previous article, and we already defined the schema for the mutations. As a refresher, they are (in Schema.res):

input RecipeInput {
  id: String!
  title: String!
  ingredients: String!
  instructions: String!
  tags: [String]!
  deleted: Boolean!
}

input TaggedRecipesInput {
  tag: String!
  recipes: [String]!
  deleted: Boolean!
}

type Mutation {
  setRecipe(recipe: RecipeInput!): Recipe!
  setTaggedRecipes(taggedRecipes: TaggedRecipesInput!): TaggedRecipes!
}

And we already implemented “dummy” resolvers in Resolvers.res:

let setRecipe = ({recipe}: Schema.recipeInput): Store.recipe => {
  id: recipe.id,
  title: recipe.title,
  ingredients: recipe.ingredients,
  instructions: recipe.instructions,
  tags: recipe.tags,
  deleted: recipe.deleted,
  updatedAt: Js.Date.now(),
}

let setTaggedRecipes = ({taggedRecipes}: Schema.taggedRecipesInput): Store.taggedRecipes => {
  tag: taggedRecipes.tag,
  recipes: taggedRecipes.recipes,
  deleted: taggedRecipes.deleted,
  updatedAt: Js.Date.now(),
}

The next step is to map these two mutations to actions in the store.

A SetRecipe action and reducer

Our action type in Store.res currently has two variants:

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

Let’s add a SetRecipe action that accepts a recipe object and either replaces or updates it in the array:

  | SetRecipe(recipe)

The SetRecipe variant accepts a complete recipe type.

This change introduces a compiler warning in our reducer because the switch statement is no longer exhaustively handling all possible cases. This is easily fixed by adding a handler for that arm:

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)
  | SetRecipe(recipe) => setRecipe(state, recipe)
  }
}

Of course, that still fails to compile, since we don’t have a setRecipe function.

Like all reducers, setRecipe must accept the initial state and some input (in this case, a recipe) and return the new state. We are just overwriting whatever is currently in the state.recipes Map.String.t for the given recipe’s id with the new recipe. This can be done with the same Map.String.set function that we used in the article where we implemented addRecipe:

let setRecipe = (state: state, recipe: recipe) => {
  {
    recipes: state.recipes->Map.String.set(recipe.id, recipe),
    tags: state.tags,
  }
}

Hook the action up to the mutation

Change the dummy setRecipe mutation in Resolvers.res to dispatch the new action:

let setRecipe = ({recipe}: Schema.recipeInput): Store.recipe => {
  let result: Store.recipe = {
    id: recipe.id,
    title: recipe.title,
    ingredients: recipe.ingredients,
    instructions: recipe.instructions,
    tags: recipe.tags,
    deleted: recipe.deleted,
    updatedAt: Js.Date.now(),
  }
  Store.Reducer.dispatch(SetRecipe(result))
  result
}

This is a multi-step process. First we construct the new instance of the recipe type, which differs from the recipeInput in that it has an updatedAt. Then we dispatch an action to set that recipe type in our Store. Finally, we return the same recipe, to satisfy the requirements of the mutation schema.

Lets see if it works. Run the server (npm start`) and visit the graphiql endpoint.

Construct a mutation such as the following in the live editor and click the run button:

mutation Mutation {
  setRecipe(recipe: {id: "0xabcdef", title: "fried eggs", ingredients: "eggs", instructions: "fry", tags: [], deleted: false}) {
    id
    title
    updatedAt
  }
}

It should return the input values for id and title, along with the timestamp generated by our server:

{
  "data": {
    "setRecipe": {
      "id": "0xabcdef",
      "title": "fried eggs",
      "updatedAt": 1616858572697
    }
  }
}

Now you have two choices for querying to see if the recipe showed up or not! You can hit the GET endpoint /recipes/0xabcdef in your browser or Rest client. It should return:

{
  "id": "0xabcdef",
  "title": "fried eggs",
  "ingredients": "eggs",
  "instructions": "fry",
  "tags": []
}

Or you can query the recipeRxDbFeed resolver that we created in the previous article. That resolver is meant to fulfill a sort of “cursor” interface for RxDB, so the arguments don’t make a lot of sense for a one-off query, but we are able to get a result:

query Query {
  recipeRxDbFeed(id: "any", minUpdatedAt: 1.0, limit: 5) {
    id
    updatedAt
    title
    ingredients
  }
}

The result for me is:

{
  "data": {
    "recipeRxDbFeed": [
      {
        "id": "0xabcdef",
        "updatedAt": 1616858572697,
        "title": "fried eggs",
        "ingredients": "eggs"
      }
    ]
  }
}

Setting tagged recipes

The first steps for setting tagged recipes are pretty much exactly the same as the procedure for for setRecipe. It’s so simple, in fact, that I’m tempted to leave it as an “exercise for the reader”. But I won’t leave you hanging that badly! Here are the steps:

First, a new variant on the action type in Store.res:

  | SetTaggedRecipes(taggedRecipes)

and an accompanying arm on the reducer type:

  | SetTaggedRecipes(taggedRecipes) => setTaggedRecipes(state, taggedRecipes)

and then a new function to implement that setTaggedRecipes reducer:

let setTaggedRecipes = (state: state, taggedRecipes: taggedRecipes) => {
  {
    recipes: state.recipes,
    tags: state.tags->Map.String.set(taggedRecipes.tag, taggedRecipes),
  }
}

and, finally update the mutation in Resolvers.res:

let setTaggedRecipes = ({taggedRecipes}: Schema.taggedRecipesInput): Store.taggedRecipes => {
  let result: Store.taggedRecipes = {
    tag: taggedRecipes.tag,
    recipes: taggedRecipes.recipes,
    deleted: taggedRecipes.deleted,
    updatedAt: Js.Date.now(),
  }
  Store.Reducer.dispatch(SetTaggedRecipes(result))
  result
}

A mutation to write a taggedRecipes looks like this:

mutation Mutation {
  setTaggedRecipes(taggedRecipes: {tag: "burned", recipes: ["0xabcdef"], deleted: false}) {
    tag
    updatedAt
    recipes
  }
}

and the easiest way to see if it worked is to visit /allTags

A sinister bug?

As currently implemented, the setTaggedRecipes mutation has a sinister bug. Why do I call it sinister? Because, unlike all the other bugs I’ve fixed in this series, it was not caught by the Rescript compiler.

The truth is, our logic has become complex enough that it deserves unit testing (coming up soon, I promise).

If you haven’t guessed what the bug is, yet: we are not updating the list of tags in each recipe when we set the tags. setTaggedRecipes may add or remove recipes from the tag’s list, but it doesn’t add or remove that tag from any recipes in the state.Recipes map.

This can be fixed by looping through all the recipes in the store and ensuring the tags array is consistent. This wouldn’t be very efficient if the array is large, and we might be better off looking for a different data model.

Fixing this bug in this way would make sure that this setTaggedRecipes endpoint maintains our database in an internally consistent manner. But we need to think about this endpoint in the context of the distributed system in which it is being used.

Remember, these endpoints are intended to be used by an RxDB client in the browser. The question we need to ask ourselves is: who is responsible for keeping the database state consistent: the client or the server?

If it is the server, then it is obviously correct for the setTaggedRecipes action to also update the tags on each of the recipes. It would also have to change the updatedAt timestamp on each recipe so that the client can pick up the changes.

However, if it is the client’s job to maintain consistency, we have to assume that the client will call setRecipe for each of the recipes whose tags got changed at the same time it calls setTaggedRecipes.

We know that the frontend app is supposed to work without a connection to the server. Therefore, it has to be up to the frontend to maintain that consistency. We don’t want to end up seeing this sequence of events:

  • frontend calls setRecipe for each recipe that had its tags modified
  • server updates those recipes
  • server pushes the changed recipes with the new updatedAt timestamp
  • frontend calls setTaggedRecipes for the changed tag
  • server updates both the taggedRecipes and each recipe that has had its tag changed
  • server pushes the changed recipes with the new updatedAt timestamp again

Things get even dicier when you have multiple users connected to the database, and I am not prepared to think about how tricky that is going to get right now! But the implementation as it currently sits appears to be correct in terms of behaving as a RxDB replication endpoint, so I’m going to leave it as is.

Conclusion

All things considered, these were some pretty light changes to enable the mutation interface that RxDB desires. We didn’t really learn anything new about Rescript, sadly, but hopefully you are comfortable with the idea of writing GraphQL endpoints in the language now.

In the next article, we’ll hook up these endpoints to the RxDB frontend and see if everything actually works!

If it doesn’t, I guess it’s time for that article on unit testing in Rescript…