Graphql Endpoints to sync with RxDB in Rescript (Part 1)
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 it’s now time to add sync to turn it into a progressive web app.
In earlier articles, I created an offline-enabled “recipe book” entirely in Rescript. More recently, I created a graphql express server and set it up to have the same data model as the frontend app.
This article will (finally) marry the two with graphql endpoints to allow RxDB to sync the frontend with the backend.
Patreon
This series takes a lot of time to write and maintain. The only thing that has kept me invested in it is the support I’ve received on my 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.
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.
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 I’ve used in a couple previous articles. It has a basic graphql endpoint
in place, but with little more than a hello world
schema.
If you haven’t been following along with these articles, you can git checkout
the
refactor
branch to have the same starting point.
If you want to keep up with the commits for this article, they are in the offline-sync branch. Each commit roughly maps to a section in this article.
Defining the GraphQL Schema
The RxDB docs have a page describing what the graphql endpoints needs to look like to sync a collection. The endpoints are per collection, so we need two endpoints for each collection: a query and a mutation.
The query needs to be sortable on the updatedAt
field we defined previously,
and documents must never be deleted, but instead must have a deleted
flag
set to true.
The server endpoint for each collection needs to return an array of documents
in that collection that are more recent than a given element. So the query for
a recipe
would contain the id
and updatedAt
of the most recent document
and return any newer documents in order. We can also supply a limit
to behave
as pagination.
So let’s start by replacing the schema
definition in our server’s
Schema.res
with the following:
type Query {
recipeRxDbFeed(id: String!, minUpdatedAt: Float!, limit: Int!): [Recipe!]!
taggedRecipesRxDbFeed(
tag: String!
minUpdatedAt: Int!
limit: Int!
): [TaggedRecipes!]!
}
Of course, that depends on the existence of two more graphql types, Recipe
and TaggedRecipes
. We can port those over from the db model in Store.res
.
Update the schema to contain these two types:
type Recipe {
id: String!
title: String!
ingredients: String!
instructions: String!
tags: [String]!
updatedAt: Float!
deleted: Boolean!
}
type TaggedRecipes {
tag: String!
recipes: [String]!
updatedAt: Float!
deleted: Boolean!
}
The schema also needs mutations. RxDB expects a straightforward mutation that just accepts a single instance of the document being modified:
type Mutation {
setRecipe(recipe: RecipeInput!): Recipe!
setTaggedRecipes(taggedRecipes: TaggedRecipesInput!): TaggedRecipes!
}
But, once again, we need to define the RecipeInput
and TaggedRecipesInput
types. Our inputs won’t allow setting the updatedAt
field, but will
otherwise be identical to the query types:
input RecipeInput {
id: String!
title: String!
ingredients: String!
instructions: String!
tags: [String]!
deleted: Boolean!
}
input TaggedRecipesInput {
tag: String!
recipes: [String]!
deleted: Boolean!
}
Update the Rescript query schema types
We changed the schema passed into buildSchema
, but we haven’t told
Rescript about the new models. Let’s change our schema types to use using the “real”
models we have defined in Store.res
.
Replace the greetByNameArgs
and rootValue
at the top of Schema.res
with a
new rootValue
type:
type rootValue = {
recipeRxDbFeed: recipesRxDbFeedInput => array<Store.recipe>,
taggedRecipesRxDbFeed: taggedRecipesRxDbFeedInput => array<Store.taggedRecipes>,
setRecipe: recipeInput => Store.recipe,
setTaggedRecipes: taggedRecipesInput => Store.taggedRecipes,
}
The feed input types are records with the three argument values:
type recipesRxDbFeedInput = {id: Store.id, minUpdatedAt: float, limit: int}
type taggedRecipesRxDbFeedInput = {tag: Store.tag, minUpdatedAt: float, limit: int}
The two mutation Input
types are a bit trickier because the type of the
argument value is essentially a “recipe without an updatedAt field”. But
rescript has no way to model this. I toyed with the idea of just using the
Store.recipe
and Store.taggedRecipes
type and intentionally choose to
ignore the updatedAt
field. However, I decided that would eventually bite me
in the ass.
So I chose a more verbose, but type-safe solution instead. I have a
new recipeInputFields
type for the type of the thing being passed in, and
then a recipeInput
type to contain that type. Do the same for
taggedRecipesInputFields
and it looks like this:
type recipeInputFields = {
id: Store.id,
title: Store.title,
ingredients: Store.ingredients,
instructions: Store.instructions,
tags: array<Store.tag>,
deleted: bool,
}
type taggedRecipesInputFields = {
tag: Store.tag,
recipes: array<Store.id>,
deleted: bool,
}
type recipeInput = {recipe: recipeInputFields}
type taggedRecipesInput = {taggedRecipes: taggedRecipesInputFields}
type rootValue = {
recipeRxDbFeed: recipesRxDbFeedInput => array<Store.recipe>,
taggedRecipesRxDbFeed: taggedRecipesRxDbFeedInput => array<Store.taggedRecipes>,
setRecipe: recipeInput => Store.recipe,
setTaggedRecipes: taggedRecipesInput => Store.taggedRecipes,
}
For what it’s worth, if I had been writing Typescript, I would have used the
Omit<Recipes, 'id'>
type here, but Rescript doesn’t have anything like that
(with good reason).
Bolierplate resolvers
Our Resolvers.res
file is no longer compiling because we don’t have
greetByNameArgs
anymore, and the rootValue
doesn’t satisfy the new
Schema.rootValue
type. Let’s add some dummy endpoints to make it all compile
again. Replace the entire Resolvers.res
with the following:
let recipeRxDbFeed = ({id, minUpdatedAt, limit}: Schema.recipesRxDbFeedInput) => {
[]
}
let taggedRecipesRxDbFeed = ({tag, minUpdatedAt, limit}: Schema.taggedRecipesRxDbFeedInput) => {
[]
}
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(),
}
let rootValue: Schema.rootValue = {
recipeRxDbFeed: recipeRxDbFeed,
taggedRecipesRxDbFeed: taggedRecipesRxDbFeed,
setRecipe: setRecipe,
setTaggedRecipes: setTaggedRecipes,
}
These don’t actually do anything, but it compiles and is enough to run a couple of quick queries in your local graphiql to make sure that it’s not crashing or anything. I used these queries:
query Query {
recipeRxDbFeed(id: "John", minUpdatedAt: 1.0, limit: 5) {
id
}
taggedRecipesRxDbFeed(tag: "John", minUpdatedAt: 1.0, limit: 5) {
tag
}
}
mutation Mutation {
setRecipe(recipe: {id: "0xabcdef", title: "fried eggs", ingredients: "eggs", instructions: "fry", tags: [], deleted: false}) {
id
title
updatedAt
}
setTaggedRecipes(taggedRecipes:{tag: "shells", recipes:["0xabcdef"], deleted: false}) {
tag,
recipes,
updatedAt
}
}
The Recipe feed
Let’s first implement the recipeRxDbFeed
function. We can use a structure
similar to the one chose for the REST endpoints when we first set up the
express server in an earlier article. However, we’ll have to do some additional
filtering and sorting to return the correctly requested subset. This is going
to require a fair bit of data structure manipulation, so the first thing I did
was put open Belt
at the top of the file. This gives us slightly safer
array’s and immutable maps in our namespace, among other things.
We’ll be operating on the state.Recipes
map where state
was returned by
Store.Reducer.getState()
. The first thing we need is the list of values in
that map as an array, which is easily extracted by piping it into
Map.String.valuesToArray
:
Store.Reducer.getState().recipes
->Map.String.valuesToArray
We are only interested in recipes that have been updated more recently than the
timestamp supplied as an argument. So let’s add a filtering pass to the
pipeline. The equivalent of filter
from Javascript is called keep
in
Rescript’s Belt.Array
module. It accepts a function that returns a boolean
for each element in the array:
Store.Reducer.getState().recipes
->Map.String.valuesToArray
->Array.keep(r => r.updatedAt > minUpdatedAt)
The resulting array needs to be sorted by updatedAt
, falling back to sorting
by id
if the updatedAt
is the same to ensure consistent ordering of two
distinct recipes with the same timestamp.
Belt
provides a SortArray
module for performing sort operations on an
array. This module contains a few functions to check if an array is sorted or
to perform binary search. We are currently interested specifically in the
stableSortBy
function. In addition to the array itself, passed in using pipe,
it requires a callback function in the form of a standard comparator: Given two
values, return an integer indicating what order they should come in. It’s a
large conditional, but it looks quite elegant:
->SortArray.stableSortBy((r1, r2) => {
if r1.updatedAt > r2.updatedAt {
1
} else if r1.updatedAt < r2.updatedAt {
-1
} else if r1.id > r2.id {
1
} else if r1.id < r2.id {
-1
} else {
0
}
})
If this looks surprising, remember that an if
statement is an expression,
which means it has a return value. The return value is the last line of
whichever arm gets executed.
Finally, we need to implement the ‘limit’ argument, which just means slicing off the front of the sorted array.
->Array.slice(~offset=0, ~len=limit)
However, the Rescript compiler has issued a warning that highlights a problem I
would have totally missed otherwise. We aren’t using the id
variable at all.
Looking back at the RxDB docs to see why they needed it, I discovered that the
filtering condition needs to check the id as well as the updatedAt
.
In retrospect, this is obvious, since the filter should be using the same
criteria as the sort. Thank you for finding that bug, Rescript compiler! Here’s
the entire recipeRxDbFeed
function, now with the correct filtering in place:
let recipeRxDbFeed = ({id, minUpdatedAt, limit}: Schema.recipesRxDbFeedInput) => {
Store.Reducer.getState().recipes
->Map.String.valuesToArray
->Array.keep(r => {
if r.updatedAt == minUpdatedAt {
r.id > id
} else {
r.updatedAt > minUpdatedAt
}
})
->SortArray.stableSortBy((r1, r2) => {
if r1.updatedAt > r2.updatedAt {
1
} else if r1.updatedAt < r2.updatedAt {
-1
} else if r1.id > r2.id {
1
} else if r1.id < r2.id {
-1
} else {
0
}
})
->Array.slice(~offset=0, ~len=limit)
}
You can test this function by first adding several recipes using the REST
endpoints we implemented in a previous article, then issuing the appropriate
queries in graphiql. Because I was testing this quite a few times, I created
the following Python script to hit the addRecipe
endpoint several times
and print out the ids:
import requests
import json
for i in range(8):
print(
requests.post(
"http://localhost:3000/addRecipe",
data=json.dumps(
{"title": f"{i}", "instructions": "blah", "ingredients": "blat"}
),
headers={"content-type": "application/json"},
).json()
)
If you’re wondering why I didn’t use Rescript; I knew how to do it in Python without looking anything up. Pragmatic is better than pedantic.
The taggedRecipes feed resolver
We should be able to pretty much copy-paste the recipes resolver for
taggedRecipesRxDbFeed
. Indeed, all I had to do was substitute tag
for id
and the endpoint was complete:
let taggedRecipesRxDbFeed = ({tag, minUpdatedAt, limit}: Schema.taggedRecipesRxDbFeedInput) => {
Store.Reducer.getState().tags
->Map.String.valuesToArray
->Array.keep(r => {
if r.updatedAt == minUpdatedAt {
r.tag > tag
} else {
r.updatedAt > minUpdatedAt
}
})
->SortArray.stableSortBy((r1, r2) => {
if r1.updatedAt > r2.updatedAt {
1
} else if r1.updatedAt < r2.updatedAt {
-1
} else if r1.tag > r2.tag {
1
} else if r1.tag < r2.tag {
-1
} else {
0
}
})
->Array.slice(~offset=0, ~len=limit)
}
Conclusion
The article is getting long and I’m a little short on time, so I’m going to defer the mutations for next week. They will require new actions and reducers, so I want to make sure I take the time to get it right.
Other than the SortArray
module, we didn’t encounter much new Rescript
knowledge in this article. It’s good to feel like I’m reaching a steady state
with the language, and I hope you are feeling it, too! There’s still plenty to
cover, of course, but I’m easily able to do daily coding in the language now,
and it continues to be the most pleasant coding experience I’ve encountered.
I’ll be honest with you: I’m not actually sure if this is going to work! I haven’t actually finished this and hooked it up to RxDB yet. I’m learning as much as you are here, though I hope my experience distilling information is making it a little easier for you than it is for me!
I’m getting busy now, but I look forward to writing these articles and hope to continue churning them out for my Patrons. Please Join them if you want to support my efforts or preview articles before they are published. I have a healthy backlog of articles available to paying subscribers that will continue to roll out on a one to two week schedule.