Modeling RxDB in Rescript, Part 1
Introduction
Like many, I am excited by the resurgence of interest in offline web applications.
I’m also amused by it. Offline apps were all the rage a dozen years ago or so, but then everyone got excited about native. Facebook famously made the mistake of doubling down on mobile web and abruptly switching to “mobile first” shortly before their IPO.
I remember reading a quote years ago-and I wish I could remember the exact wording-about how the tech industry is stuck in an endless loop. People propose the same things over and over and think its new. I’m rather startled to discover that I’m old enough to have seen this observation proven multiple times.
To quote one of my favourite authors and writing instructors, Brandon Sanderson, “Everyone wants to be new and edgy, but they all end up being new and edgy in the same way”. He was talking about writing in the second person, but it applies to the entire silicon valley startup ecosystem, too.
Forgive an old man for digressing… now where was I? ;-)
Oh yes! I’ve got this long-running series of articles about the Rescript programming language. I’ve recently been looking for a project that would allow me to study binding Rrightescript to third-party libraries and I was hoping to experiment with promises as well. Building an offline-enabled Rescript app allowes me to do both.
Patreon
This series takes a lot of time to write and maintain. I’ve been working solid 8 hour days on these articles for a few weeks to get the quantity and quality of content you are reading. I won’t be able to keep that up once my next job begins, and without some level of motivation, I’ll probably drop the series altogether.
On the other hand, if you’re interested in providing some of that motivation, 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 helps to know if people appreciate it.
As a bonus for patrons, articles will be published there at least two weeks before they make it to my public blog.
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.
Overview of the project
I’m going to keep running with the recipes app example I’ve been using, but will be starting from scratch instead of extending the existing project. This will allow us to take advantage of a create-react-app template specifically designed for progressive web applications (it’s not terribly different from the default one).
We’ll be using the RxDB database to do the offline storage. It has some really nice reactive elements and can be connected to Graphql in the future to sync to a cloud db.
As a warning, this is a big project and is going to take several articles.
Some links
- You might find the official create-react-app docs valuable reading
- Create-react-app is iscluding Workbox for the service worker stuff
- RxDB will provide our offline data storage
- Hasura has a great course on offline-enabled apps (Hasura is an outstanding project with the best documentation I have ever read).
Let’s begin
Start by creating a react app with the PWA (progressive web app) features enabled:
npx create-react-app rescript-offline --template cra-template-pwa
Open index.js
and remove two characters: change
serviceWorkerRegistration.unregister()
to
serviceWorkerRegistration.register()
.
This, by default, caches all the static assets for your site. It works offline. So we’re done!
The commits for this project can be found in the
db-connections
branch of the
rescript-offline repo if
you want to follow along. It can be helpful to see how the JS output (also
checked in) of the rescript compiler changes as you modify the .res
files.
Does it work offline?
Run npm run start
and navigate to localhost:3000 in
Chrome. Other browsers probably behave similarly, but I haven’t checked.
Show the dev tools and navigate to the Application
tab. Tick the Offline
checkbox, and, finally, refresh the page.
If you did everything right you should see “No Internet”.
Wait, what?
Actually, service workers are not enabled when running the dev server. You really don’t want stuff to be cached when you’re expecting them to update during development!
Let’s try this again. First, untick the Offline
box so it doesn’t mess you up later.
Then run npm run build
. This will spit out a build
directory with all your
assets (this is what you’d deploy to production).
cd build
and run npx http-server
to start an http server running the
“production” build. Now head to localhost:8080 since
http-server
defaults to a different port.
Open the dev tools again and tick that Offline
checkbox in the Applications
tab. If you’re wondering why I told you to untick it and then tick it again,
remember that you’re effectively accessing a different site (on port 8080).
It’s a completely different data store.
Refresh the page. It should still render, though if you check the network tab,
it won’t have made any requests. Alternatively, uncheck the Offline
box and
hit ctrl-C
in the terminal to exit the http-server
process.
Reload the page again. It should render as if the server were still running.
Now let’s get a feel for something that can go wrong when working with service
workers. Edit App.js
and change the text contents.
Run npm run build
and start the npx http-server
process again.
Refresh the page in the browser and note that it does not contain your new changes. By default, the service worker will not load the new content until you close all tabs connected to it.
Try that: close the tab, then open a new one and navigate to localhost:8080.
Now it should show the edited version of the file.
To get around this when you are testing stuff locally, you can use the
Update on reload
box in the dev tools. It’s right beside Offline
. However,
you do need some kind of strategy to instruct the user to reload service
workers when releasing new versions of an app.
Port the app to rescript
Now that we have an offline-capable javascript app, let’s port it to Rescript! Luckily, we already know how to do this. I knew I wrote that article for a reason!
If you don’t want to do all that, you can just checkout the basic-cra-sw branch of my rescript-offline repo.
If you do want to do it yourself, you can pretty much follow the setting up a rescript app word for word.
I didn’t bother porting the service worker code itself to rescript.
There is one minor change to the Index.res
file. We need to register the
service worker. by importing the register
function from
`serviceWorkerRegistration and calling it.
So put
@module("./serviceWorkerRegistration") external register: unit => unit = "register"
at the top of Index.res
.
And put register()
at the end.
A caution on adding more static files
If you add a new
image image to
the static/
folder, you can’t just reference it as
<img src="/rescript-brandmark.png" />
. The problem is that create-react-app
won’t know to update the manifest to precache that image, so it won’t be loaded
into the browser cache until you visit a page that requires it while connected
to the network.
Instead, do the same thing we did in the earlier article with the logo:
@module("./rescript-brandmark.png") external rescriptLogo: string = "default"
<img src={rescriptLogo} />
Always make sure any files you want to include in the manifest come through create-react-app loaders.
Introducing RxDB
We’ll be using RxDB for our offline database. It is a couchdb-like nosql store that works in the browser (on top of IndexedDB). It is “Reactive” meaning that changes to the database can fire events on subscriptions. This effectively means your React state can BE your db state.
Most notably for this tutorial, RxDB does not have Rescript bindings that I could find. So we get to implement our own! I’m not going to implement every method on every object in the project; just the ones we need as they come up. This is a recommended approach for Rescript-Javascript interop. In fact, the Rescript team doesn’t generally recommend making third-party Rescript libraries to wrap JS!
As all great projects must, start with installing dependencies:
npm install --save rxdb rxjs pouchdb-adapter-idb
.
Create a new Db.res
file and add some imports from the Javascript RxDB package.
We’ve used this syntax in a couple earlier articles, but this is our first deep
look at binding to JS:
@module("pouchdb-adapter-idb") external pouchDbAdapter: 'whatever = "default"
@module("rxdb") external addRxPlugin: 'whatever = "addRxPlugin"
@module("rxdb") external createRxDatabase: 'whatever = "createRxDatabase"
Ignoring all the gobbledygook, this is just creating three variables, as though
we had called let pouchDbAdapter = ...
. Of course, the gobbledygook has
meaning:
- The
external
tells Rescript that it is defining a binding to a value defined in an external javascript module or file @module(...)
tells us which javascript (npm) package the external value is inside. It could also refer to a specific Javascript file in your repo, as we did above with theserviceWorker
. Basically, this is whatever you would put at the end of animport
statement.'whatever
in all three cases is the type of the object that is being bound to. For now, we are lazily using'whatever
. The'
indicates it’s a generic type; it is a standin for an “any” type, and we can treat it as an opaque Javascript object. This obviously isn’t typesafe in Rescript, but it lets us get up and running quickly.= "default"
tells Rescript to bind to the default export in that module. You might also put a specific value.= "createRxDtabase"
and= addRxPlugin
tell rescript to bind to those specifically export names in whichever module was passed to@module
(in this case,rxdb
). For comparison, in Javascript you’d import these asimport { addRxPlugin, createRxDatabase } from 'rxdb'
. Basically, whatever goes after the=
sign in an@module
declaration goes inside curly braces in animport
statement.
Next, RxDB requires us to install an adapter for the database backend. In our
case, we’ll be using a pouchDbAdapter
that stores everything in IndexedDB in
the browser. RxDB actually works server-side with a different set of adapters,
but we won’t be using that functionality today. Install the pouchDbAdapter
with:
addRxPlugin(pouchDbAdapter)
This just calls the addRxPlugin
function that we imported as an external
javascript property from rxdb
. We are passing in the pouchDbAdapter
that
we imported as an external for the default import from pouchdb-adapter-idb
.
Looking at this, we can make the addRxPlugin
type a little more obvious. We
don’t need to care what type pouchDbAdapter
is, so let’s give it a generic
type 'pouch
But we do know addRxPlugin
is a function that accepts that
generic type and returns nothing. So we can change the typings on those two
declarations to:
@module("pouchdb-adapter-idb") external pouchDbAdapter: 'pouch = "default"
@module("rxdb") external addRxPlugin: 'pouch => unit = "addRxPlugin"
Creating a database
We’ll improve the types the createRxDatabase
in a bit, but first let’s see if
we can call it as is. Add a make
function to the Db.res
module:
let make = () => {
createRxDatabase({"name": "recipes", "adapter": "idb"})
}
Next open App.res
and add a call to this new function from inside the App
s
make
function:
let db = Db.make()
Js.log(db)
Run the app to test it out. I recently discovered that my vim rescript
plugin is automatically building files for me most of the time, so I only have
to run yarn start
and not yarn start:res
.
Open the browser console and see if it logged anything. If all goes well, it
should log a Promise
.
Time to learn about async and await in Rescript. Spoiler alert: There is no async and await in rescript. My impression is that the Rescript devs are planning for it, but…. no promises.
Rescript does have bindings to JS promises, but they are clunky because they
are optimized for pipe-last (|>
) syntax instead of the pipe-first (->
)
operator that Rescript encourages.
So, instead, we’ll use the rescript-promise
package, which is maintained by a
core Rescript developer. I believe it is going to be added to the Rescript Js
API in the future, probably as a Js.Promise2
package.
First npm install --save @ryyppy/rescript-promise
. Don’t forget to open
bsconfig.json
and add this to the bs-dependencies
: "@ryyppy/rescript-promise"
.
Now we can pipe the output of createRxDatabase
(which we now know, from our
logging, is a promise) into the Promise.then
function. For now, all I want
to do is log the value the promise returns to see what it is:
let make = () => {
createRxDatabase({"name": "recipes", "adapter": "idb"})->Promise.then(db => {
Js.log2(`Loaded database`, db)
db
})
}
Note the use of log2
which means “log two arbitrary arguments”. We were
previously using log
, which means “log exactly one argument”.
Now I see two lines of output in my console:
> Promise {<pending>}
> Db.bs.js:14 RxDatabaseBase {internalStore: PouchDB, idleQueue: IdleQueue, token: "bcnqwb9xhl", _subs: Array(1), destroyed: false, …}
The first line is from the original log
we put in App.res
. If you aren’t
familiar with promises, this shows that the Promise is returned immediately,
before whatever is inside the then
function is ready to execute. The second
line is the new log line, which shows that we’ve successfully constructed a
RxDatabase.
Improve typing on the createRxDatabase parameter
createRxDatabase
and the RxDatabase
it returns are important user-facing
functions. We should provide safe Rescript types for them instead of the
generic type we started with.
The first thing we can do is note that createRxDatabase
is a function. So we
can change the type to 'whatever => 'whateverelse
.
We also know the return value is a Promise
, which improves our type to
'whatever => Promise.t<'rxdbThingy>
.
Promise
is the module we imported before, while t
is the type inside that
module that represents JS Promises.
But we still have two generics. We can fix one of them by adding a new type for the database parameters. The parameters are summarized here. If you are doing your own bindings to libraries, I’ve found it’s also helpful to look at the typescript bindings, such as here.
I don’t feel like modelling all of the paramters fields right now. We are not
building a stand-alone library for public consumption. So I’ll just model the
name and adapter, which I’m already passing in. The options I do choose to
model can be encapsulated in a normal Rescript record type (This needs to go
before the createRxDatabase
external declaration):
type createRxDatabaseOptions = {
name: string,
adapter: string,
}
Now we can change the createRxDatabase
external type to
createRxDatabaseOptions => Promise.t<'rxdbThingy>
.
Rescript will now raise an error because we were previously passing an object
into createRxDatabase
, but we have now told the compiler that the function
accepts a record. These are distinct concepts in Rescript, though I won’t go
into the details right now. Syntactically, they are very similar except:
- Objects have keys wrapped in quotes
- Records have bare keys
Just remove the quotes from the words "name"
and "adapter"
in the call to
createRxDatabase
as follows:
createRxDatabase({name: "recipes", adapter: "idb"})->Promise.then(db => {
Js.log2(`Loaded database`, db)
db
})
Building the RxDatabase type
Now we are ready to tackle the final generic in createRxDatabase
: the return
type inside the promise.
That type will be modelled as the t
type for the Db.res
module.
Interestingly, we don’t need to specially define anything about t
except that
it exists. Just add type t
at the top of the file. It doesn’t need any
fields, but it needs to go before the createRxDatabase
external so the
external can reference it.
Then make one final change to the createRxDatabase
external definition: the
return type changes from Promise.t<'rxdbthingy>
to Promise.t<t>
.
This is sufficient for rescript to infer that t
is the type of the js
object returned by createRxDatabase
.
Now we can write functions that guarantee they accept that object without rescript actually needing to know anything else about it.
However, we now have a compiler error. The db
return type from Promise.then
is illegal. Promise.then
is defined such that then
must return a Promise.t
.
Luckily, it’s easy to return a Promise
: Just call the Promise.resolve
function, passing in the return value. There are two ways you can do this; take
your pick:
Promise.resolve(db)
db->Promise.resolve
The behaviour in browser should be unchanged. It should still print two things to console:
- The Promise returned by
createRxDatabase
- the
Loading database <RxDatabaseBase>
Modelling a method on RxDatabase: destroy
Modelling methods isn’t too hard, but the syntax is intimidating at first. The most difficult part is mapping the default object oriented “objects have methods” mental model to the functional “functions accept objects” model.
RxDatabase has several useful methods that we may want to model. We’ll start
with the destroy
method because it’s pretty simple and we know we’ll need it
for full lifecycle management.
According to the docs for destroy:
- it is a method
- it takes zero arguments
- it returns a
Promise
of nothing
However, our function will need to take a t
as one argument, in lieu of an
object having a method.
In rescript, we use @send
to bind a function to a rescript method. The syntax
is is essentially the same as @module external
. Both have the format:
<decorator> external <name>: <type> = <js name>
For methods, we just use a different decorator. So it looks like this:
@send external destroy: t => Promise.t<unit> = "destroy"
Pretty straightforward, really. When I first encountered it in the docs, I was feeling a bit intimidated by binding to js, but once you break it down as above, it’s not too bad.
Now, instead of calling ‘db.destroy()’ as we did in Javascript, we can call either
destroy(db)
or db->destroy
.
Using an effect
Let’s actually use this destroy
method by adding a cleanup effect to App
.
First, change the app to store the db inside a useState
and set it from
inside a useEffect
. This allows us to set the db only after the promise
resolves:
let (db, setDb) = React.useState(() => None)
React.useEffect1(() => {
let dbPromise = Db.make()->Promise.map(db => {
setDb(_ => Some(db))
db
})
None
}, [setDb])
Js.log2("db is", db)
There’s a lot to pay attention to in a few lines here! As we’ve seen in earlier
react articles, useState
must be called with an initialization function. In
our case, we initialize it to the None
option.
Another Rescript oddity is that useEffect1
needs to inform rescript how long
the dependency array is. That’s what the 1
in useEffect1
is for. If you had
two deps, it would be useEffect2
. It’s similar to log2
.
Also somewhat unexpectedly, useEffectX
needs to return an option. If you are
use to Javascript React, you probably don’t return anything from useEffect
very often. This means you are implicitly returning undefined
. In Rescript you
have to do that explicitly.
When the function returns a Some
, the value of the option is the cleanup
function. We haven’t reached that point yet, so we’ll just return None
.
Also recall that our Db.make
function returns a Promise.t
. We pass the
promise into the Promise.map
function. Promise.map
is identical to
Promise.then
except the callback function doesn’t have to return a
Promise.t
. map
will automatically wrap the returned value in a new
Promise.t
. It’s the same idea as Option.map
.
When the Db.make
promise resolves, we pass the result to setState
. Finally,
note how we assign the promise that creates the database to the dbPromise
variable. We’ll use this in our cleanup function later to access the database.
If you run the app now, the Js.log2
line should be called twice:
- “db is None” on the first render
- “db is <RxDatabaseBase>” after the effect completes
Returning a cleanup function from the effect
We can replace the None
above with a Some(() => {...})
. The callback inside
the Some
will call be able to call Db.destroy
on the underlying db
. we
can get the db out of the dbPromise
using Promise.then
. Note that we don’t
want to access the outer db
from useState
directly because the effect
should not depend on that value. Otherwise the effect would be called every
time the db
changes, which would result in a dreaded render loop.
Here`s the new return value:
Some(
() => {
let _ = dbPromise->Promise.then(db => db->Db.destroy)
},
)
We are piping the dbPromise
into Promise.then
. The promise has probably
already resolved by the time the cleanup function is called, but it doesn’t
really matter.
Whenever the promise does resolve, the db gets piped into the Db.destroy
method we originally wrote.
Db.destroy returns a Promise.t
, and we can just let Rescript return that
directly becasue then
expects to be returning a Promise
. So we don’t need
an explicit Promise.resolve
this time.
We assign the resulting promise to the “unknown” variable, _
. You might have
expected to be able to just ignore the value, but rescript requires all function
calls that return a value to assign that value. The error message for this is a
little ambiguous, so make sure to look out for it.
This section has been a little complicated, so here’s the whole useEffect1
for comparison:
let (db, setDb) = React.useState(() => None)
React.useEffect1(() => {
let dbPromise = Db.make()->Promise.map(db => {
setDb(_ => Some(db))
db
})
Some(
() => {
let _ = dbPromise->Promise.then(db => db->Db.destroy)
},
)
}, [setDb])
Modelling the recipes type
Rxdb is a document-oriented database. We need to specify the structure of those documents using a schema. Before we do that, though, let’s define the rescript types we will use. These are the types we use in our code, as opposed to the ones stored in the database. We’ll map the db types to the Rescript types in a later step.
We can adapt the types we used in the Store
for our introductory React app.
However, I want to add some extra type documentation around the fields. And I
want to add the id
field that I overlooked in the original react article.
Create a new file named Model.res
:
open Belt
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>,
}
I created several new types that all map directly to primitives. They honestly
don’t do much other than supply a bit of documentation. Hopefully they will
help us avoid, for example, submitting instructions where ingredients were
expected. RxDB expects primary keys to be strings, so I made id
a string
type. This is in contrast to what I did in the previous article on an express
server. Don’t worry, though, I have an article queued up on how delightful it
is to refactor in Rescript to fix that!
The recipe
and taggedRecipes
types are the meat of our stored state.
Modelling the schema
RxDB expects schemas to be modeled in the json-schema standard, although it only supports a subset of json-schema fields.
We did a lot of JSON in the express article, but let’s do a recap to explore our options. There are four ways to model JSON in Rescript:
- Bind to the javascript
JSON.parse
function and blindly assign the output a type. This will throw an exception if it is unparsable JSON, and will simply behave incorrectly if it has the wrong type. So only use it on JSON that you trust to be correctly formatted. - Use the
Js.Json
module to parse individual properties one at a time. This requires verbose code and is somewhat frustrating to write, but it’s guaranteed to be type-safe. We covered a lot of that in the rescript-express article. - Use a third-party library such as
bs-json
to write JSON decoders and encoders. This is type-safe. I would ordinarily recommend it by default, butbs-json
suffers from being designed to work with Reason’s “pipe-last” syntax, which feels unergonomic in Rescript. There is a new library namedjzon
you might want to look at. - Leave the json type in Javascript and just bind it to a generic in Rescript. This isn’t useful in all situations, but it will work perfectly for the purpose of our JSON schema.
Create a new file named src/Schema.json
as follows:
{
"recipes": {
"title": "recipes schema",
"version": 0,
"description": "A single recipe",
"type": "object",
"properties": {
"id": {
"type": "string",
"primary": true
},
"title": {
"type": "string"
},
"ingredients": {
"type": "string"
},
"instructions": {
"type": "string"
},
"tags": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
}
}
}
},
"tags": {
"title": "tags schema",
"version": 0,
"description": "Mapping of tags to recipe IDs",
"type": "object",
"properties": {
"tag": {
"type": "string",
"primary": true
},
"recipes": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
Hopefully this is easy to read, even if you aren’t familiar with jsonschema. We
have defined two “collections” named recipes
and tags
. The former contains
documents with the id
, title
, ingredients
and instructions
of the recipe, as
well as an array of tag names. The tags
collection is like an index; it
contains documents with a tag name and array of recipe ids with that tag.
The version
is probably one thing that needs explaining. RxDB
will expect you to provide a migration step if this version number is not 0.
Now we need to load this schema in our Db.res
file. This is easier than you
might expect:
@module("./Schema.json") external schema: 'schema = "default"
Js.log(schema)
Because we used create-react-app, and the default webpack pipeline for CRA knows how to load json, we just have to bind a value to the file in question. Breaking it down in case you haven’t quite internalized the format of an external declaration yet:
@module("./Schema.json")
tells Rescript which module to look for the value inexternal
tells Rescript it’s an external JS bindingschema
is the name of the imported value in the Rescript namespace'schema
is the type of the imported value. In this case, we use a generic type (denoted by'
) to indicate we either don’t care about the type or will specialize it later.default
is the name of the value being imported from the Javascript namespace. In this case, it’sdefault
, since webpack will spit the json object out as a top-level value.
The Js.log
line allows us to double check that the value was imported correctly.
One thing I like about this is that we pragmatically decided to write JSON in JSON instead of rigorously typing it. There may be bugs in the way this JSON is written, and we won’t find those bugs until runtime. However, in practice, we would find them immediately because we can’t do anything without a functioning schema in the database. So there’s no point in wasting a lot of time trying to model the types of the full jsonschema spec.
That said, if you were trying to wrap the RxDB library as a public rescript module, instead of just wrapping the bits of it that we need for this project, you’d probably want to supply stronger type guarantees to your API consumers. Be pedantic only when it is pragmatic to do so.
Adding db collections
The RxDB documentation on addCollectios makes it sound simple, but the process of mapping it to Rescript is not. I spent a solid day sorting out what needed to be done to write this section!
We’ll need to do several things, and I’ll outline them before we start so you can see where I’m going:
- Model the paramater that gets passed into
addCollections
. It can take several options, but for starters, we’ll just model theschema
, which is required. - Model the
addCollections
method itself as a function call. It accepts a dictionary ofAddCollectionsOptions
and returns aPromise.t
of the collections that have been added. I had to look at the source code to determine what the return value is. - Construct the options that get passed into
addCollections
for our specific usecase (ie: recipes and tags collections). - call
addCollections
with those options. - Handle the returned promise.
Modelling addCollectionsOptions
According to the RxDB docs and source code, addCollections
accepts a dictionary
object where the keys are names of collections, and the values are an object
with several fields. Our goal is to model that object.
The most important of these fields is the schema
. In fact, I’m not even going
to bother modelling the other properties that can go on there yet. We can flesh
it out, if necessary, once the end to end experience is in place.
Further, the schema is going to be different for each collection. Our recipes
collection has a different schema from tags
. This means that schema
will
need to be a generic type.
Here’s how to define the addCollectionsOption
type with a generic schema:
type addCollectionsOptions<'schema> = {schema: 'schema}
The generic type is named 'schema
. We don’t know anything about this type
except that it exists. Note that the 'schema
name only takes effect within
this type. That means it’s totally different from the 'schema
that we used as
a generic on the Schema.json
external.
Yes, I could have made that less ambiguous by usinga different variable name,
but there’s actually method to this madness. You see, the Schema.json
external does not actually have a specific enough type. We won’t be able to
pass its values into our Rescript functions without giving more information
about those values. Specifically, we need to know that it is an object
containing two keys: recipes
and tags
, though we don’t need to model what
types the values are for those keys.
Change the Schema.json
external as follows:
@module("./Schema.json") external schema: {"recipes": 'schema, "tags": 'schema} = "default"
The type is all that’s changed, but there are a couple things to notice about that type:
- It is a Rescript object not a record. The difference in syntax is as subtle as the difference in meaning. I’ll leave the official docs to explain the difference in meaning. The difference in syntax is that in records, the keys are not surrounded with quotes and in objects they are. In general, records are more type safe, but objects are easier for certain kinds of JS interop. Since we are modelling a complex JS interop, I chose objects.
- Because we don’t care what type the object field are, we assign them
a generic type. It is still named
'schema
. In fact, the'schema
type is still, from Rescript’s perspective, completely different from the'schema
we used inaddCollectionsOptions
. However, from our perspective, as coders, they are now referring to the same type of thing.
Modeling the addCollections method
It took a bit of research to figure out how addCollections
behaves. It
accepts a dictionary of collections options and returns a promise with the
newly added collections as values. However, I realized we don’t really need
that return value; I’m just going to discard it. This means I can model it as a
generic type inside the promise.
On the javascript side, this is a method, so we use @send
to model it, like
we did with destroy
. The first argument is the object that the method will
be called on, which is the database itself (the t
in our module).
So here’s the entire external declaration:
@send
external addCollections: (t, Js.Dict.t<addCollectionsOptions<'schema>>) => Promise.t<'collections> =
"addCollections"
It’s only two lines of code, but a bit complicated to read. Break it down into
@send external
modifier, addCollections
name, the function type, and the
name of the addCollections
method on the object being modelled (the
RxDatabase, t
) to make sure you understand it.
Constructing the options and calling the method
Now we can update the database constructor to use Js.Dict
to construct a
dictionary mapping collection names as keys to addCollectionsOptions
objects
as values. Those options objects will have only one field, for now: the
schema
as a generic type. We’ll look up the schema on the schema
object
from the Schema.json
external.
We can’t call the method until we actually have a database object, so this work
happens inside the then
handler on the promise returned by
createRxDatabase
.
First construct an empty options
dict and set two fields on it (shown here
with the rest of the make
method for context):
let make: unit => Promise.t<t> = () => {
createRxDatabase({name: "recipes", adapter: "idb"})->Promise.then(db => {
let options = Js.Dict.empty()
options->Js.Dict.set("recipes", {schema: schema["recipes"]})
options->Js.Dict.set("tags", {schema: schema["tags"]})
})
}
Unlike Belt.Map.String
, which we discussed several articles ago, the empty
value on a Js.Dict
is a function. The returned dictionary is a mutable
javascript object, so it needs to generate a new one for every copy of the
dictionary. Under the hood, this maps to a very simple var options = {}
in
Javascript.
The Js.Dict.set
function takes an options as the first argument (via ->
),
a key and the value. The value is constructed dynamically as an object with
a schema
key and the vasue looked up in the schema
dict from Schema.json
.
Calling our addCollections
function is a one-liner:
db->addCollections(options)->Promise.then(_ => Promise.resolve(db))
We pipe the db into the externally declared addCollections
function,
passing in the options
we just constructed.
The resulting promise is piped into Promise.then
. We are discarding the
returned value (with the _
default name). Instead, we return a resolved
Promise
with the db
. If you unwrap everything, this is the promise that
returns a value from make
, so it needs to fulfill make
s type signature.
How did I know I needed to do that? Rescript pointed out a type error between what I was returning and what I thought I was returning. This would not have been caught by Javascript code. To be fair, it would be a bit simpler in Javascript because I would be using async/await syntax.
If you are still logging the db in App.res
, you can open it in the dev tools
console and should see the two collections under the collections
property.
Conclusion
I just realized this is getting rather long and I better split it out to multiple articles. We haven’t actually written a ton of code to get to where we are, but there was a lot to understand when it comes to modelling JS in Rescript. Once you understand it, however, it’s a lightweight process that is easy to do ad-hoc.
In the next article, we’ll model actual collections and build RxDB queries for our offline-database.
The commits for this article can be found in the db-connection branch.