Collaborative Counter¶
In this chapter, we will implement a collaborative counter.
If you haven't done so yet, head over to the first tutorial chapter to instantiate a Sobamail starter app in developer mode.
App Initialization¶
Launch the Sobamail app and locate your applications name under the Apps
folder. Then expand it and click the instance id to launch the user interface.
You should now see the default welcome page. It's just a static index.html
file.
While static pages are fabulous, they are also a bit boring. Let's bring this app to life and initialize a Svelte app using Vite:
- Navigate to your develroot. You should see it right there on the initial "Hello World!" page.
To do it manually, you need to first locate your profile folder on your
computer and then go to the directory where the starter app files reside. As
an example, if your user name is test@sobamail.com your app assets could very
well be in a folder named: apps/devel/counter.test.user.app.sobamail.com.
-
Once inside the develroot, you should see the following directory structure:
. ├── GITINIT.md ├── Mutator.mjs ├── README.md └── assets └── index.htmlFor apps in developer mode, the
Mutator.mjsandassets/index.htmlfiles are hardcoded entry points to you your backend and frontend code respectively. -
Let's initialize the Svelte project under the
uifolder: -
Since we will have Vite manage our assets folder, we first remove it and add it to
.gitignore -
Now is a good time to initialize a git repository. Let's create our first commit:
-
Since Sobamail doesn't (yet?) support Javascript development servers, we will modify the Vite configuration to include a watcher job that rebuilds frontend assets when it detects file changes.
So let's create a
vite.config.devel.tswith the following content:import {svelte} from "@sveltejs/vite-plugin-svelte" import {defineConfig} from "vite" export default defineConfig({ plugins : [ svelte() ], build : { minify : false, outDir : "../assets", emptyOutDir : true, }, });... and add the watch job to
package.json--- a/ui/package.json +++ b/ui/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev-watch": "vite build --watch --config ./vite.config.devel.js", "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" -
Finally, let's launch our favorite IDE and get to work:
At this stage, our code repository should look similar to this:
Let's reload the app interface in Sobamail: 
Sending Increment Requests¶
What a nice coincidence! The Svelte demo app is also an increment-only counter! 😎😎
As expected, the counter gets reset when the UI is reloaded. We must add state persistence to it:
Let's start by having the frontend send click events to the backend.
Since the preferred serialization format of the Sobamail backend is msgpack let's add it:
```sh
npm install @msgpack/msgpack
git add package*.json
git commit -a -m "Install msgpack"
```
Load up Counter.svelte and apply the relevant bits from the following patch:
Let's have a closer look:
-
We first import msgpack functions and determine a namespace for the events. In Sobamail, all objects must belong to globally unique namespaces. An easy way to ensure this is to append
/model/v1to where your project is hosted.Since our app is hosted on
https://github.com/sobamail/counterwe end up with the following namespace value: -
Next, Let's continue by implementing the
incrementRequestfunction. Here's the important excerpt:async function incrementRequest(value: number) { const requestData = encode({ namespace: namespace, name: "IncrementRequest", content: { value: value }, });Note
Sobamail backend is not restful -- there are no methods or headers, just objects encoded in the
Structured Message Contentformat. It's a very simple format that only forces you to denote the namespace and name of the object in transit in addition to its content, as can be seen above.The reason is simple: Sobamail backend only reacts to emails. What we are doing is effectively sending a bare-bones email message to the backend with content
{value: <number>}.If this was a conventional web application, we probably would be issuing a
POSTrequest to/api/v1/incrementwith the equivalent JSON contents.Don't fret! If you miss restful interfaces, nothing stops you from adding a
methodproperty and aheadersobject to your objects. It's completely up to you!The rest is fairly standard use of the Javascript fetch API, except the URL part.
Until further notice, all backend requests in Sobamail apps need to go to
/api/v1endpoint. It's just another hardcoded magic constant. -
Lastly, we modify the
incrementfunction from the demo to call ourincrementRequestfunction.
Now that the data is being sent, let's implement the handler in the backend.
Writing the Counter Value¶
Now we are looking at the Mutator bits of the same patch
Let's have a closer look:
-
First, we create objects that the Backend will react to. Since our event namespace is
https://github.com/sobamail/counter/model/v1, we must create the Javascript module for objects in thegithub.com/sobamail/counter/model/v1folder next toMutator.mjs.Each imported Sobamail module must contain the above namespace export that must match the import string.
Then we add the
IncrementRequestobject referred by the frontend:As you may have noted, it's just a barebones object. You can explicitly define properties or make similar adjustments to your taste, but it's not strictly required since objects in Javascript are dynamic.
-
Now let's switch to the Mutator.mjs file. We start by creating the SQL table to store the counter value:
constructor() { soba.schema.table({ name : "counter", // (a) insertEvent : AddCounter, // (b) deleteEvent : DeleteRow, // (c) columns : [ // (d) { name : "name", checks : [ {op : "!=", value : null}, {op : "lww", value : true}, {op : "typeof", value : "text"}, ], }, { name : "value", checks : [ {op : "!=", value : null}, {op : "typeof", value : "integer"}, ], }, ], }); }-
The name of table is
counter, the local identifier for the table. -
All tables in Sobamail apps are identified by the unique object key of their
insertEvent. When a row is inserted to thecountertable, Sobamail stores and propagates theAddCounterevent to all replicas. -
When a row is deleted from any table in Sobamail, it's replicated using the
DeleteRowevent, aka a tombstone. This is just required boilerplate for the moment. -
The
columnsarray contains our field names and their optional constraints. In this case, we have a mandatory uniquenamecolumn using the LWW (Last-Write-Wins) method for conflict resolution, as well as a mandotory value column. Currently, LWW is the only way of generating a column with aUNIQUEconstraintAll Sobamail tables also define the
idcolumn implicitly.
When all is said and done, the above code results in the following SQL schema:
-
-
We must not forget to add the AddCounter event to the events module
... and import it in the
Mutator.mjs:... and finally add it to the objects of interest map:
static objects = new Map([ + [ AddCounter.KEY, false ], [ DeleteRow.KEY, false ], + [ IncrementRequest.KEY, false ], ]);You will note that we haven't yet specified the hash value. That's only important when we reach the deployment stage. We will get back to this.
-
Finally, let's delve into the
process()method, the heart of our application.We start by extracting the content from the incoming message, which can be anything between a proper email message and a bare-bones request from the frontend.
let object = Message.extractObject(message, meta); if (! object) { soba.log.info("Unrecognized content"); return; }We now have to write handlers for incoming objects one by one. Let's start with the simplest one,
AddCounter:if (key == AddCounter.KEY) { // This is a mutation, so write it straight into the database return soba.data.insert("counter", object); }Let's now write the handler for the
IncrementRequest, coming from the UI:if (key == IncrementRequest.KEY) { if (! content.value) { // (a) throw new Error("IncrementRequest.value is empty or zero"); } let parent = soba.data.findLast("counter", {name : "counter"}); if (! parent.object) { // (b) soba.data.insert("counter", { name : "counter", value : content.value, }); } else { // (c) soba.data.update("counter", { parent : parent.hash, column : "value", op : "+", value : content.value, }, { // (d) uuid : soba.type.uuid.v4(), } ); } return; }Let's break this down further:
- If the incoming value is
undefined,nullor just0, it's an invalid request, we refuse to handle it. - If this is the first time we receive an increment request, we insert the row and be done with it.
- If the row already exists, we update the
valuecolumn in the row. - Since we want each increment request to be distinct from each other, we
append an uuid so the replication engine does not deduplicate increment
requests with the same value. This value is not written to the
countertable.
- If the incoming value is
With this final piece, the backend module is done!
Now let's read the values back and complete the counter tutorial.
Reading Counter Value Back¶
Here's the patch:
Let's first focus on the frontend code:
-
We send the
CounterValueGetobject the usual way. Since the response will also be encoded using msgpack, we need to read the from thearrayBufferproperty of the response object.const responseData = await response.arrayBuffer(); // (...) const responseObject: any = decode(responseData); // (...) return responseObject[3];According to the Structured Message Content documentation the magic value "3" is the msgpack SMC key for "content" so we directly return it to the caller.
The rest is standard Svelte stuff.
Let's now focus on the backend:
-
We add the
CounterRequestGetandCounterRequestPutto thev1.mjsmodule import them to theMutator.mjsmodule and add them to the objects of interest. -
We now add the handler for the
CounterRequestGetobject:// ui request if (key == CounterValueGet.KEY) { const rows = soba.db.exec( "SELECT value FROM counter WHERE name='counter'").data; let value = 0; if (rows.length > 0) { value = rows[0][0]; } let resp = new CounterValuePut(); resp.value = value; return soba.task.respond(resp); }We read from the database using
soba.db.exec()and and return using thesoba.task.respond()functions provided by the runtime.
That's it! we now have a functional increment-only counter implementation.
Now, let's deploy it.