The Big Macro

This post is about a design decision I would like to try. One Big Form, that combines lots of information in one single form that controls the domain data of my app.

The title may be misleading since I am talking about a big form that gets processed by either multiple macros or multiple implementations of the same macro.

I am talking about an extension of fulcro's defmutation macro. Like print going to stdout in clojure and js/console in clojurescript I would like defmutation to feed the database on the server and state in the browser.

Background

I have a fulcro app in limited inhouse production that evolved. In the first iteration, it pulled data from the server, which used pathom.

Second Iteration

Since the domain is very deeply nested and entangled, the more domain cross references got presented, the more the app got useful, the bigger the pulled trees. Loading highly complex data from the server means the data travels un-normalized with many duplicated nodes. Also 95 % of what gets pulled gets pulled in the next load again, and again … and again. So I introduced caching. Users started to open many windows at once so when does the cache get stale? Windows needed to inform each other about updates and when a different user updated data the server needed to push. So the server kept subscriptions to know who to push to when something updated. This got messy and entangled.

Third Iteration

So I am in the next iteration now. I tuned my data model so I could get rid of pathom and pull directly from datalevin on the server. This sped up pulls by a factor of 15.

With the db so lean I can run it in a webworker

So I have a very big cache that simply caches everything. A service worker sets up a shared worker if supported, a dedicated worker if shared worker support is missing (chrome on android). Since datalevin is a fork of datascript a bit of tweaking of transit-writers can copy a whole filtered db from the server into the client in about 10 seconds. This gets cached by the webworker. Once up, the webworker opens a websocket connection to the server.

Pulls and queries

For pulling and queries fulcro now uses a remote that pulls from the worker, not the server. The db-code in the server and worker for pull and query are identical differing in only in the requiere

(ns database
(:require #?(:clj  [datalevin.core :as d]
             :cljs [datascript.core :as d])))

Why is this different from pulling from the server? Because we now pull from cache, so we can uptimise for many small pulls instead of having to optimize for aggregated caching pulls.

Server push

Server push gets enriched by the result of the datalevin transaction

[[#datascript/datom [1 :person/firstname "Peter"]]

The server pushes to the worker, which transacts the datoms and pushes the original mutation to connected windows and tabs.

Mutations

Mutations from the browser could get send to the server directly but to have a good offline experinece fulcro browser uses the worker remote and hands them off to the worker which transacts as the server would do but uses datascripts with-db to keep pending mutations apart from transactions confirmed/pushed by the server.

The worker answers the mutation from the UI and forwards it to the server.

The server authoratively processes the mutation, transacts to the authorative database, adds the datoms from the transaction to the mutation and pushes it to all connected workers, including the one that forwarded the mutation from its UI.

This enriched mutation is timestamped and receives a serial number and gets persisted to disk (zfs which snapshots every few minutes to another hot standby machine). The serial numbers turn this into an ordered mutation-log.

The worker transacts the data and removes the corresponding optimistic update from the db-with-database of pending transactions.

So mutations start in the browser, get passed to the worker, from there to the server, from there to all connected workers, from these to all windows or tabs. Server push is the extended life of a mutation. Mutations start and end in fulcro in the browser.

Mutation Log

Whenever the worker looses the websocket connection it can just ask for a replay of mutations since the last seen serial id. Also, on push, the worker will realise if it missed a mutation and can, ask for a replay.

If or when the server db gets lost it can be reconstructed from any snapshot by replaying the mutations too.

Not repeating myself

In this architecture the Fulcro mutation gets used in the UI, as the mutation that changes the database on the server, as the pushed data and as the optimistic db-with-database in the worker.

All the stuff of transacting to database, pushing, updating the pending db-with-database repeats on all mutations.

Fulcros defmutation macro introduced the idea of a common destructuring of the params for all the actions involved. The database actions on the server and in the worker work with exactly the same params. So to not repeat myself the database-actions on the server and worker can be integrated into the defmutation form.

The Big Macro

This could be the file net/markusgraf/hhh/domain/assignment/model.cljc

(ns net.markusgraf.hhh.domain.assignment.model)
(defmutation new-assignment [{:as params
                              :assignment/keys [id name position]
                              :keys [tx]}]
  (action [{:keys [state]}]
    (log/debug "new-assignment action start" params)
    (ns/swap!-> state
      (assoc-in [:assignment/id id] (assoc params :ui/saving? true))
      (ns/integrate-ident [:assignment/id id]
                          :append (conj position :assignment/_position)
                          :append (conj position :ui/assignments)))
    (log/debug "new-assignment action worked"))
  (remote [env] true)
  (db-action [{:keys [user-db-id]}]
    (if (blank? name)
      {:error "will not add assignment without name"}
      [(assoc params :assignment/owner user-db-id)]))
  (ok-action [{:keys [result state handlers] :as env}]
    (let [{:keys [body]} result
          key            (first (keys body))]
      (log/debug "new-assignment ok-action" name)
      (if (and (= key (symbol ::new-assignment))
               (map? (key body)))
        (do
          (log/debug "good response from worker calling pushed-new-assignment")
          ((:push-action handlers) env))
        (do (ns/swap!-> state
              (ns/remove-entity state [:assignment/id id]))
            (js/alert "Fehler bei der Anlage:" body)))))
  (push-action [{:keys [state]}]
    (let [assignment-ident [:assignment/id id]
          company          (get-in @state (conj position :position/company))]
      (log/debug "pushed-new-assignment" name)
      (swap!-> state
        (cond->
            tx   (update-in assignment-ident merge (dissoc params :tx))
            true (ensure-idents-exist assignment-ident position)
            true (integrate-ident assignment-ident
                                  :append (conj position :assignment/_position)
                                  :append (conj position :ui/assignments)
                                  :append [:assignments]
                                  :append (conj company :ui/assignments)
                                  :append (conj company :ui/assignments-none))
            true (update-in assignment-ident dissoc :ui/saving?)))))
  (error-action
      [_]
    (js/alert "Keine Verbindung zum Server! Kann nicht speichern!")))

This is big but it brings in the fullstack view of the mutation

net.markusgraf.hhh.domain.assignment.model/new-assignment

This code would be used on the server, the worker and the browser-client. They would have three seperate definitions of the macro though.

In the UI

The UI uses Fulcro's defmutation which is patche to elide the db-action. The browser-client should not know about datascript or datalevin.

On the Server

The macro gets parsed as by Fulcros's defmutation but everything except the db-action is elided. The db-action is executed in the context of datalevin, persisting to disk, timestamping and distributing the push.

On the Worker

The macro gets parsed as by Fulcros's defmutation but everything except the db-action is elided. The db-action is executed in the context of datascript and the housekeeping of the with-db-database. This throws away anything not db related so browser stuff does not get included in the advanced build.

How to integrate this into the development environment

Build / module dependent

The easiest would be if there were reader conditionals per module. Then the three macros would be implemented in three seperate namespaces like client.defmutation, worker.defmutation and server.defmutation. The ns declaration of net/markusgraf/hhh/domain/assignment/model.cljc would be

(ns net.markusgraf.hhh.domain.assignment.model
 (:require #?(:client [client.defmutation :refer [defmutation]]
              :worker [worker.defmutation :refer [defmutation]]
              :clj    [server.defmutation :refer [defmutation]])))

Seperate paths per build / module

If the build environment supports separate paths per build then namespaces by the same name coud exist in different paths like

  • src/server/defmutation
  • src/client/defmutation

I can imagine that this would confuse dead code elimination since two modules would see two different versions of the same namespace in the same build. In this scenario the three implementations of (defmacro defmutation ...) would live in three sepearte files with identical namespaces and names.

Some information available to clj at compile time

All these macros are implemented as

(defmacro
  ^{:doc "some"} defmutation
  [& args]
  (defmutation* &env args))

So if there were some way to know at compile time what module and build we are in this could branch to three separate implementations of defmutation*.

Pros

Do not repeat yourself

All the actions on the server, the worker and the UI close over the same params and env. The model stays together.

Things stay together when refactoring

I regularily reorganize or regroup files into different directories and namespaces. I often miss something when doing this.

Mutations stay in their namespaces

This keeps all mutation code in its proper namespace, regardless of whether it runs on the server, worker or browser.

An alternative would be to keep Fulcro's defmutation unpatched in the browser with just the push-action added and have a separate def-db-mutation in a separate file and namespace. It could not live in the same namespace and file since it would not get elided when not used and would pull the database into the browser and the browser code into the webworker. This would require the def-db-mutation macros to do namespace translation on the mutations between the db namespaces

Cons

UI and DB stuff get intermingled

I need to be careful what I require.

Server has not dead code elimination

On the backend defmutation would eliminat the ui actions of the mutation, but the namespaces would still be pulled. So the jar would grow by unused ui code. I can live with this.

The elision problem

All this boils down to an elision problem. I do not want the db code to pull in dependencies into the advanced build of the browser, nor do I want the browser code pulled into the advanced build of the worker. The worker should pull in a different database than the server and shall maintain a second with-db database while the server takes care of persistence and server-push.

In the past, with only one client and one server I would just have two model files, model.clj and model.cljs. All mutations lived in duplicated code that had to be manually synced. But at least the namespaces were the same.

With the introduction of the worker I now need three versions of each mutation and since the worker can not read clj files model.clj needs to be renamed to model.cljc which can not coexist with model.cljs.

So three different macros parsing the same model.cljc file would be a way out.

Get rid of macros

If all the mutation definitions lived in declarative datastructures three separate namespaces could parse this common datastructure. But this would not happen at compile time but at runtime. So this could not eliminate calls depending on a database from the build code.