Skip to content

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 icon 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.html
    

    For apps in developer mode, the Mutator.mjs and assets/index.html files are hardcoded entry points to you your backend and frontend code respectively.

  • Let's initialize the Svelte project under the ui folder:

    npm create vite@latest ui -- --template svelte-ts --no-rolldown --no-interactive
    
  • Since we will have Vite manage our assets folder, we first remove it and add it to .gitignore

    rm -rf assets
    echo /assets > .gitignore
    
  • Now is a good time to initialize a git repository. Let's create our first commit:

    git init
    git add .gitignore Mutator.mjs README.md ui
    git commit -m "Initial commit"
    rm -vf GITINIT.md
    
  • 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.ts with 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:

    npm i
    npm run dev-watch
    

At this stage, our code repository should look similar to this:

Let's reload the app interface in Sobamail: counter

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/v1 to where your project is hosted.

    Since our app is hosted on https://github.com/sobamail/counter we end up with the following namespace value:

    @@ -1,8 +1,41 @@
     <script lang="ts">
    +    import { encode, decode } from "@msgpack/msgpack";
    +    export const namespace = `https://github.com/sobamail/counter/model/v1`;
    +
    
  • Next, Let's continue by implementing the incrementRequest function. 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 Content format. 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 POST request to /api/v1/increment with the equivalent JSON contents.

    Don't fret! If you miss restful interfaces, nothing stops you from adding a method property and a headers object 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/v1 endpoint. It's just another hardcoded magic constant.

  • Lastly, we modify the increment function from the demo to call our incrementRequest function.

        let count: number = $state(0);
        function increment() {
            const increment = 1;
            count += increment;
            incrementRequest(increment);
        }
    

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 the github.com/sobamail/counter/model/v1 folder next to Mutator.mjs.

    export const namespace = `https://github.com/sobamail/counter/model/v1`;
    

    Each imported Sobamail module must contain the above namespace export that must match the import string.

    Then we add the IncrementRequest object referred by the frontend:

    export class IncrementRequest {
        static KEY = `{${namespace}}${this.name}`;
    }
    

    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"},
                        ],
                    },
    
                ],
            });
        }
    
    1. The name of table is counter, the local identifier for the table.

    2. All tables in Sobamail apps are identified by the unique object key of their insertEvent. When a row is inserted to the counter table, Sobamail stores and propagates the AddCounter event to all replicas.

    3. When a row is deleted from any table in Sobamail, it's replicated using the DeleteRow event, aka a tombstone. This is just required boilerplate for the moment.

    4. The columns array contains our field names and their optional constraints. In this case, we have a mandatory unique name column 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 a UNIQUE constraint

      All Sobamail tables also define the id column implicitly.

    When all is said and done, the above code results in the following SQL schema:

    CREATE TABLE IF NOT EXISTS "counter" (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name text,
        value integer,
        CHECK(name IS NOT NULL),
        CHECK(typeof(name)='text'),
        CHECK(value IS NOT NULL),
        CHECK(typeof(value)='integer'),
        UNIQUE(name)
    );
    
  • We must not forget to add the AddCounter event to the events module

    export class AddCounter {
        static KEY = `{${namespace}}${this.name}`;
    }
    

    ... and import it in the Mutator.mjs:

    import {
        AddCounter,
        IncrementRequest,
    } from "https://github.com/sobamail/counter/model/v1"
    

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

    1. If the incoming value is undefined, null or just 0, it's an invalid request, we refuse to handle it.
    2. If this is the first time we receive an increment request, we insert the row and be done with it.
    3. If the row already exists, we update the value column in the row.
    4. 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 counter table.

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 CounterValueGet object the usual way. Since the response will also be encoded using msgpack, we need to read the from the arrayBuffer property 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 CounterRequestGet and CounterRequestPut to the v1.mjs module import them to the Mutator.mjs module and add them to the objects of interest.

  • We now add the handler for the CounterRequestGet object:

        // 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 the soba.task.respond() functions provided by the runtime.

That's it! we now have a functional increment-only counter implementation.

Now, let's deploy it.