Controlled hallucination

Learning Elm Part 10 - Cleaning up program structure

Published: - Reading time: 9 minutes

I know, I know. I’ve been saying “next up, encryption!” for about forever now. I do have a branch with encryption working, it has the new end-to-end tests and everything.

But before I add it to the main branch, I thought it would be better to make a few structural changes.

So here we go!

As always, you can find a deployed version here, with the Elm Debugger here, and the source code here. The only noticeable changes would be if you inspect the messages in the Elm Debugger. This is one of those refactors where there is no value-added for the end user other than hopefully a faster delivery of new features in the future.

Table of contents

Elm, what was it anyway?

I’ve been talking about our Elm app, and our “JavaScript-land”, which is of course, kind of a fantasy. Elm compiles to JavaScript. The runtime is JavaScript. But there is a logical separation of what is controlled by our JavaScript Elm-compiled app, and what is not controlled by it.

Just to recap, when the browser loads our app, the HTML calls our src/index.js which loads our Elm app and hooks it into the DOM:

const app = Elm.Main.init({
  node: document.getElementById("root"),
});

That app is our “Elm runtime” where we’ve been defining “ports”. Some ports send messages from Elm to JavaScript through subscriptions (I call them ELM ==> JS in my Elm code) and other ports allow us to send messages back to Elm through send messages. In ASCII art:

 |---------|                 |----------|
 |        --> subscriptions -->         |
 | Elm App |                 | JS Layer |
 |        <----- messages <----         |
 |---------|                 |----------|

For example, when we want to persist our transaction, we use a subscribe() port from app.ports, and when we send data back to Elm, we use a send() one:

// src/index.js

// ELM ==> JS port using subscriptions
app.ports.saveTransaction.subscribe(async function (elmTxn) {
  // map + persist
});

// JS ==> ELM port using send()
async function sendTransactionsToElm() {
  const result = await db.allDocs({ include_docs: true, descending: true });
  const transactions = mapResultToElmTransactions(result);
  app.ports.gotTransactions.send(transactions);
}

Moving all control flow to Elm

So far, so good. The issue is that I’ve been very lax, and cutting some corners regarding control flow. I have a mixture of “who is in charge”, to put it one way, which was fine until now:

  1. In JavaScript, I start everything by pushing data to Elm.
  2. In Elm, I store a transaction but we cede control back to JavaScript, who again pushes all transactions back to Elm.
  3. Etc.

I’d like to put Elm in the drivers seat, so to speak, and take this opportunity to untangle the JavaScript code. Will the result be easier to understand? I am not sure. It will certainly be more code. The intent of the code ought to be clearer, at least.

Making implicit architecture explicit

In my ASCII art above, there are basically 3 components:

  1. Elm
  2. Glue Code
  3. JavaScript

In JavaScript, I’ll define a DbPort which will abstract talking to the databases. The DbPort will manage the databases:

APP
+----------------------------------+
| Elm <=> [Glue] <=> DbPort <=> Db |
+----------------------------------+

If you draw it differently, and squint, you could call it “onion architecture” where the dependencies point inwards.

+------------------------------------+
|     +---------------------------+  |
|     |      +-----------------+  |  |
|     |      |         +----+  |  |  |
| Elm | Glue | DbPort  | Db |  |  |  |
|     |      |         +----+  |  |  |
|     |      +-----------------+  |  |
|     +---------------------------+  |
+------------------------------------+

Albeit a square onion, and the Glue actually knows about both Elm and the DbPort. I’d probably describe the dependency tree like this:

uses

uses

uses

Glue

Elm

DbPort

DB

But the true function of Glue is wiring up Elm and DbPort.

DbPort

Our DbPort is pretty simple:

class DbPort {
  constructor() {}

  /**
   * This method is used for our test-only APIs
   */
  async openDbs() {}

  /**
   * Returns OK with settings, or FirstRun
   */
  async initialize() {}

  /**
   * Saves the settings, and returns the updated record
   */
  async saveSettings(elmSettings) {}

  /**
   * Gets all transactions
   */
  async getTransactions() {}

  /**
   * Save a transaction and returns the updated record
   */
  async saveTransaction(elmTxn) {}

  /**
   * Bulk-save transactions
   */
  async saveTransactions(elmTransactions) {}

  /**
   * Delete a transaction
   */
  async deleteTransaction(id, version) {}

  /**
   * Destroys the databases.
   */
  async deleteAllData() {}
}

It uses a small Db module which is the one that will hold the encryption stuff:

import PouchDb from "pouchdb-browser";

/**
 *
 * @param {String} name
 * @returns {PouchDB.Database}
 */
async function buildDb(name) {
  return new PouchDb(name);
}

export { buildDb };

Yes, not much now, but in the next post, we will be adding lots of stuff there!

Glue module

Our glue module is filled with the old src/index.js code. This is the part that wires up Elm with JavaScript, and knows how to talk to both. I called it ElmPort, given it is full of app.ports.

import { InitResponseFirstRun, InitResponseOk } from './DbPort';
import buildSample from './SampleData';

function buildGlue(app, dbPortIn) {

    let dbPort = dbPortIn;

    app.ports.initialize.subscribe(async () => {
        try {
          const resp = await dbPort.initialize();
          if (resp instanceof InitResponseFirstRun) {
            app.ports.gotFirstRun.send();
          } else if (resp instanceof InitResponseOk) {
            app.ports.gotInitOk.send(resp.settings);
          } else {
            console.error('Unknown response', resp);
            app.ports.gotInitError.send(`Unknown response ${JSON.stringify(resp)}`);
          }
        } catch (e) {
          console.log(e);
          app.ports.gotInitError.send(e.message);
        }
    });
// (...)

export { buildGlue };

Changes in the Elm code

In Elm, saving a transaction, saving settings, were all “fire and forget” type of codes. I sent the data through the port, and called it a day.

Now I added the missing callbacks. I must say this adds lots of code for things that I can’t do much if they fail. For example, when I save a transaction, I now have TransactionSaved and TransactionSavedError messages, with their corresponding ports transactionSaved and transactionSavedError. However, if you want to receive data, you have to consider the possibility that what comes in is broken, even if you want to bring in an error message as a string, you end up with:

type Msg
    = SubmitForm
    | TransactionSaved (Result Json.Decode.Error Transaction)
    | TransactionSavedError (Result Json.Decode.Error String)
-- (...)

update : Msg -> State -> ( State, Cmd Msg, Bool )
update msg model =
    case msg of
        TransactionSavedError (Err _) ->
            -- TODO: render the error :/
            ( { model | saving = False }, Cmd.none, False )

        TransactionSavedError (Ok _) ->
            -- TODO: render the error :/
            ( { model | saving = False }, Cmd.none, False )
--(...)

Maybe the developer sending the error from JavaScript (me) is sending an integer, so Elm forces you to check this.

The DevApi module

Another piece of code that was implicit in src/index.js was our small API to manipulate the application state in our end-to-end tests. This code I am ambiguous about. In some parts, it more or less ends up being the same code that is in ElmPort.js - it actually was the same code before. And again, I need to manipulate both Elm and the database, so it takes in both dependencies:

import buildSample from "./SampleData";

class DevApi {
  constructor(appPorts, dbPort, onDeleteAllData) {
    this.appPorts = appPorts;
    this.dbPort = dbPort;
    this.onDeleteAllData = onDeleteAllData;
  }

  async saveSettings(settings) {
    const newSettings = await this.dbPort.saveSettings(settings);
    this.appPorts.gotInitOk.send(newSettings);
  }

  async saveTransaction(transaction) {
    await this.dbPort.saveTransaction(transaction);
  }

  async sendTransactionsToElm() {
    const transactions = await this.dbPort.getTransactions();
    this.appPorts.gotTransactions.send(transactions);
  }

  async importSample() {
    await this.dbPort.saveTransactions(buildSample());
  }

  async deleteAllData() {
    await this.dbPort.deleteAllData();
    this.onDeleteAllData();
  }
}

export { DevApi };

The only strange thing I did was how I am handling the “delete all data” stuff, because as I destroy the databases, I need to recreate them, and hook them back into our DbPort. This is done in our src/index.js, which now looks like this:

import "./main.css";
import { Elm } from "./Main.elm";
import * as serviceWorker from "./serviceWorker";
import { DbPort } from "./DbPort";
import { DevApi } from "./DevApi";
import { buildGlue } from "./ElmPort";

const exposeJsApi = process.env.ELM_APP_EXPOSE_TEST_JS == "true";

async function main() {
  const app = Elm.Main.init({
    node: document.getElementById("root"),
  });

  let dbPort = new DbPort();

  const glue = buildGlue(app, dbPort);

  if (exposeJsApi) {
    /**
     * This function creates a DevApi instance that re-creates
     * itself after deleteAllData is called.
     *
     * It assigns window.ElmExpenses to the latest one.
     */
    const buildDevApi = () => {
      window.ElmExpenses = new DevApi(app.ports, dbPort, async () => {
        dbPort = new DbPort();
        glue.setDbPort(dbPort);
        buildDevApi();
        await dbPort.openDbs();
      });
    };

    buildDevApi();
  }
}

main();

serviceWorker.unregister();

End-to-end tests

I had to tweak the end-to-end tests in a few places, basically because of the implicit push from JavaScript to Elm. Now when I setup the state of the app to contain previous transactions, I have to push them myself to Elm.

Luckily, after that, and moving some code from src/index.js that belonged in the cypress domain, all was good to go and after this big refactor, the tests were still green:

 npm run cypress

> cypress
> cypress run


DevTools listening on ws://127.0.0.1:39199/devtools/browser/65ed0e33-1b96-4836-b10d-99cf1fc67bbb

====================================================================================================

  (Run Starting)

(...)
====================================================================================================

  (Run Finished)


       Spec                                              Tests  Passing  Failing  Pending  Skipped  
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
   crud-transactions.feature                00:12        9        9        -        -        - 
  ├────────────────────────────────────────────────────────────────────────────────────────────────┤
   edit-settings.feature                    00:08        7        7        -        -        - 
  ├────────────────────────────────────────────────────────────────────────────────────────────────┤
   empty-list.feature                       00:01        3        3        -        -        - 
  ├────────────────────────────────────────────────────────────────────────────────────────────────┤
   first-run.feature                        00:01        2        2        -        -        - 
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
      All specs passed!                        00:23       21       21        -        -        -  

Final thoughts

Now the src/index.js is clearer. The intent is clearer. There are more files, and more code. Each component could (cough, cough, should) be unit tested now that dependencies are clearer.

Moving control of what happens when to Elm certainly made the code in Elm grow. Handling this “RPC” style of communication with JavaScript is a bit tedious, I won’t lie. Failures should be very rare, as Elm is talking to an IndexedDB in the browser, basically. There are no network failures to be handled. Sure, we might run out of space in our allowed allocation, we might have bugs, etc.

I also decided to stay in the current page until the callback arrived, which forced me to handle a “saving” state, during which it would be a mistake to allow another SubmitForm. I will probably revisit this later, given that failures to write to the local database should be very rare. In the previous version, data was sent to be persisted, and we were back to the list page, no more form present, no possible double-submit.

So now, our near empty src/Db.js is ready to abstract away encryption and expose some edge cases as messages that will be propagated upwards.

Until next time!