Graphql Endpoints to sync with RxDB in Rescript (Part 2)
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.
Other articles in series
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 eachrecipe
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…