axiom//axiom-cljs.core A Client Library

Author: Temporarily Removed  (temporarily@removed.com)
Date: 25 April 2018
Repository: https://github.com/temporarily/removed
Version: 0.4.1

(enable-console-print!)

1    connection

connection creates a connection to the host.

It takes a URL and a the following optional keyword parameter:

  • :ws-ch, which defaults to a function of the same name in the chord library, and
  • :atom, which defaults to clojure.core/atom, and is used to construct a mutable box to place the user's identity.It returns a map containing the following keys:
  • A :sub function for subscribing to events coming from the host.
  • A :pub function for publishing events.
  • A :time function which returns the current time in milliseconds.
  • A :uuid function which returns some universally-unique identifier.
  • An :identity atom, which will contain the user's identity once an :init event is received from the host.
connection-1
(async done
       (go
         (let [the-chan (async/chan 10)
               mock-ws-ch (fn [url]
                            (is (= url "ws://some-url"))
                            (go
                              {:ws-channel the-chan}))
               host (ax/connection "ws://some-url"
                                   :ws-ch mock-ws-ch)]
           (is (map? host))
           (is (= @(:identity host) nil)) ;; Initial value
           ;; The host sends an `:init` event
           (async/>! the-chan {:message {:kind :init
                                         :name "some/name"
                                         :identity "alice"}})
           (async/<! (async/timeout 1))
           (is (= @(:identity host) "alice"))
           
           (is (fn? (:pub host)))
           (is (fn? (:sub host)))
           (let [test-chan (async/chan 10)]
             ((:sub host) "foo" #(go (async/>! test-chan %)))
             (async/>! the-chan {:message {:name "bar" :some :event}})
             (async/>! the-chan {:message {:name "foo" :other :event}})
             (is (= (async/<! test-chan) {:name "foo" :other :event}))
             ;; Since our mock `ws-ch` creates a normal channel, publishing into this channel
             ;; will be captured by subscribers
             ((:pub host) {:message {:name "foo" :third :event}})
             (is (= (async/<! test-chan) {:name "foo" :third :event})))

           (is (fn? (:time host)))
           (let [time-before ((:time host))]
             (async/<! (async/timeout 1))
             (is (< time-before ((:time host)))))

           (is (fn? (:uuid host)))
           (is (= (count ((:uuid host))) 36))
           (is (not= ((:uuid host)) ((:uuid host))))
           (done))))

The connection map also has an :status field, which is an atom. It holds the value :ok as long as the WebSocket channel is open, and :err once the channel has been closed.

connection-2
(async done
       (go
         (let [the-chan (async/chan 10)
               mock-ws-ch (fn [url]
                            (is (= url "ws://some-url"))
                            (go
                              {:ws-channel the-chan}))
               host (ax/connection "ws://some-url"
                                   :ws-ch mock-ws-ch)]
           (is (= @(:status host) :ok))
           (async/close! the-chan)
           (async/<! (async/timeout 1))
           (is (= @(:status host) :err))
           (done))))

2    ws-url

The connection function receives as parameter a WebSocket URL to connect to. By default, this will be of the form ws://<host>/ws, where <host> represents the value of js/document.location.host.

ws-url takes a js/document.location object and returns a WebSocket URL according to the above pattern.

ws-url-1
    (is (= (ax/ws-url (js-obj "host" "localhost:8080")) "ws://localhost:8080/ws"))

In one case, where the host value corresponds to figwheel running on the local host, we use localhost:8080 instead of the original host, to direct WebSockets to Axiom rather than figwheel.

ws-url-2
    (is (= (ax/ws-url (js-obj "host" "localhost:3449")) "ws://localhost:8080/ws"))

If a hash exists in the location, and if it contains a ?, everything right of the ? is appended to the URL as a query string.

ws-url-3
    (let [loc (js-obj "host" "localhost:8080"
                      "hash" "#foo?bar")]
      (is (= (ax/ws-url loc) "ws://localhost:8080/ws?bar")))

If a hash exists, but does not have a query string, no change is made to the URL.

ws-url-4
    (let [loc (js-obj "host" "localhost:8080"
                      "hash" "#foobar")]
      (is (= (ax/ws-url loc) "ws://localhost:8080/ws")))

3    update-on-dev-ver

When in development, Figwheel can be used to update client-side artifacts as they are being modified, without having to reload the page. However, when updating the Cloudlog logic, we wish to update content of the [views]axiom-cljs.macros.html#defview) and [queries](axiom-cljs.macros.html#defquery), and that cannot be done without refreshing.

update-on-dev-ver is a middleware function that can be called on a connection map, and returns a valid connection map.

update-on-dev-ver-0
    (let [ps (ax/pubsub :name)
          host (->  {:sub (:sub ps)
                     :pub (fn [& _])}
                    ax/update-on-dev-ver)
          received (atom nil)]
      ((:sub host) "foo/bar" (partial reset! received))
      ((:pub ps) {:kind :fact
                  :name "foo/bar"
                  :key 1})
      (is (= @received {:kind :fact
                        :name "foo/bar"
                        :key 1})))

It subscribes to axiom/perm-versions events. For each such event it will set the app-version cookie to contain the new version, so that refreshing the browser will capture the new version.

update-on-dev-ver-1
    (let [ps (ax/pubsub :name)
          published (atom [])
          host (->  {:sub (:sub ps)
                     :pub (partial swap! published conj)}
                    ax/update-on-dev-ver)
          new-ver (str "dev-" (rand-int 1000000))]
      (is (=@published [{:kind :reg
                         :name "axiom/perm-versions"}]))
      ((:pub ps) {:kind :fact
                  :name "axiom/perm-versions"
                  :key new-ver
                  :change 1})
      (is (= (.get goog.net.cookies "app-version") new-ver)))

This only applies to events with a positive :change (introduction of new versions), and only applies to development versions (that begin with dev-).

update-on-dev-ver-2
    (let [ps (ax/pubsub :name)
          host (->  {:sub (:sub ps)
                     :pub (fn [& _])}
                    ax/update-on-dev-ver)
          new-ver (str "dev-" (rand-int 1000000))]
      ((:pub ps) {:kind :fact
                  :name "axiom/perm-versions"
                  :key new-ver
                  :change 0})
      ((:pub ps) {:kind :fact
                  :name "axiom/perm-versions"
                  :key new-ver
                  :change -1})
      ((:pub ps) {:kind :fact
                  :name "axiom/perm-versions"
                  :key (str "not" new-ver)
                  :change 1})
      (is (not= (.get goog.net.cookies "app-version") new-ver))
      (is (not= (.get goog.net.cookies "app-version") (str "not" new-ver))))

4    wrap-feed-forward

Valid events that are :published by the client later return from the server almost unchanged. :subscribers will receive these events with a certain delay. Unfortunately, this delay may cause undesired behavior, when updates to the desplay are delayed until the events return from the server-side. To overcome this problem, the wrap-feed-forward middleware propagates events immediately from publishers to subscribers, on the client.

wrap-feed-forward wraps a connection map. It preserves the connection's properties, such that events :published on the client are forwarded to the server, and events coming from the server are received by :subscribers on the client.

wrap-feed-forward-1
 (let [ps (ax/pubsub :name)
     published (atom [])
     subscribed (atom [])
     host (-> {:pub (partial swap! published conj)
               :sub (:sub ps)}
              ax/wrap-feed-forward)]
 ;; Events coming from the server
 ((:sub host) "foo" (partial swap! subscribed conj))
 ((:pub ps) {:kind :fact
             :name "foo"
             :key 1
             :data [2 3]
             :ts 1000
             :change 1
             :readers #{}
             :writers #{}})
 (is (= @subscribed [{:kind :fact
                      :name "foo"
                      :key 1
                      :data [2 3]
                      :ts 1000
                      :change 1
                      :readers #{}
                      :writers #{}}]))
 ;; Event published by the client
 ((:pub host) {:kind :fact
               :name "bar"
               :key 1
               :data [2 3]
               :ts 1000
               :change 1
               :readers #{}
               :writers #{}})
 (is (= @published [{:kind :fact
                     :name "bar"
                     :key 1
                     :data [2 3]
                     :ts 1000
                     :change 1
                     :readers #{}
                     :writers #{}}])))

However, for events published by the client, subscribers on the client get immediate response.

wrap-feed-forward-2
 (let [ps (ax/pubsub :name)
     subscribed (atom [])
     host (-> {:pub (fn [& _])
               :sub (:sub ps)}
              ax/wrap-feed-forward)]
 ((:sub host) "foo" (partial swap! subscribed conj))
 ;; Event published by the client
 ((:pub host) {:kind :fact
               :name "foo"
               :key 1
               :data [2 3]
               :ts 1000
               :change 1
               :readers #{}
               :writers #{}})
 ;; is received by the client
 (is (= @subscribed [{:kind :fact
                      :name "foo"
                      :key 1
                      :data [2 3]
                      :ts 1000
                      :change 1
                      :readers #{}
                      :writers #{}}])))

wrap-feed-forward de-duplicates, so that once an event that was forwarded comes back from the server, it is not forwarded again to subscribers.

wrap-feed-forward-3
 (let [ps (ax/pubsub :name)
     subscribed (atom [])
     host (-> {:pub (fn [& _])
               :sub (:sub ps)}
              ax/wrap-feed-forward)]
 ((:sub host) "foo" (partial swap! subscribed conj))
 ;; Event published by the client
 ((:pub host) {:kind :fact
               :name "foo"
               :key 1
               :data [2 3]
               :ts 1000
               :change 1
               :readers #{"foo"}
               :writers #{}})
 ;; and received from the server
 ((:pub ps) {:kind :fact
             :name "foo"
             :key 1
             :data [2 3]
             :ts 1000
             :change 1
             ;; The server may change the readers
             :readers #{"bar"}
             :writers #{}})
 ;; is received only once by subscribers
 (is (= (count @subscribed) 1))
 ;; However, if the event is received again it is not blocked.
 ((:pub ps) {:kind :fact
             :name "foo"
             :key 1
             :data [2 3]
             :ts 1000
             :change 1
             :readers #{"bar"}
             :writers #{}})
 (is (= (count @subscribed) 2)))

5    wrap-atomic-updates

wrap-atomic-updates is a connection middleware that handles atomic updates, by treating them as two separate events.

For normal events, wrap-atomic-updates does not modify the way :sub works.

wrap-atomic-updates-1
    (let [ps (ax/pubsub :name)
          host (-> {:sub (:sub ps)}
                   ax/wrap-atomic-updates)
          published (atom [])]
      ((:sub host) "foo" (partial swap! published conj))
      ((:pub ps) {:name "foo" :key "bar" :data [1 2 3] :change 1})
      (is (= @published [{:name "foo" :key "bar" :data [1 2 3] :change 1}])))

However, for atomic updates, the subscribed function is called twice: A first time with a negative :change and the :removed value in place of :data, and the a second time with the original value of :change and :removed removed.

wrap-atomic-updates-2
    (let [ps (ax/pubsub :name)
          host (-> {:sub (:sub ps)}
                   ax/wrap-atomic-updates)
          published (atom [])]
      ((:sub host) "foo" (partial swap! published conj))
      ((:pub ps) {:name "foo" :key "bar" :data [2 3 4] :removed [1 2 3] :change 1})
      (is (= @published [{:name "foo" :key "bar" :data [1 2 3] :change -1}
                         {:name "foo" :key "bar" :data [2 3 4] :change 1}])))

6    wrap-reg

The WebSocket protocol this library uses to communicate with the gateway uses a special event not used in other places of axiom: the :reg event. This event registers the connection to streams of :fact events, and possibly asks for all existing facts that match cerain criteria. The wrap-reg middleware is used to ensure correct semantics in situations where multiple views or queries register to the same kind of events.

Consider a situation where two views register to the same :fact pattern (:name+:key combination). Since each view has its own storage atom, they will not know about each other. Each will create a :reg event with these :name and :key and register to incoming events. They will also set the :get-existing to true, to receive all existing facts that match. Unfortunately, since :get-existing was sent twice, the back-end will send two copies of each event. These events will be received by both views and so the value stored in the atom related to these events will be double its actual value on the server.

wrap-reg is intended to solve this problem. It is a connection middleware that augments the connection's :pub method. If each :reg event is sent only once, the :pub method's behavior remains unchanged.

wrap-reg-1
    (let [ps (ax/pubsub :name)
          host (-> {:pub (:pub ps)}
                   ax/wrap-reg)
          published (atom [])]
      ((:sub ps) "some/fact" (partial swap! published conj))
      ((:pub host) {:kind :reg :name "some/fact" :key 1})
      ((:pub host) {:kind :reg :name "some/fact" :key 2})
      (is (= @published [{:kind :reg :name "some/fact" :key 1}
                         {:kind :reg :name "some/fact" :key 2}])))

However, if a :reg event repeats itself, wrap-reg will make sure it is only sent to the back-end once.

wrap-reg-2
    (let [ps (ax/pubsub :name)
          host (-> {:pub (:pub ps)}
                   ax/wrap-reg)
          published (atom [])]
      ((:sub ps) "some/fact" (partial swap! published conj))
      ((:pub host) {:kind :reg :name "some/fact" :key 1})
      ((:pub host) {:kind :reg :name "some/fact" :key 2})
      ((:pub host) {:kind :reg :name "some/fact" :key 1}) ;; Sent twice
      (is (= @published [{:kind :reg :name "some/fact" :key 1}
                         {:kind :reg :name "some/fact" :key 2}])))

wrap-reg only de-duplicates :reg events. Other kinds of events (e.g., :fact) pass normally.

wrap-reg-3
    (let [ps (ax/pubsub :name)
          host (-> {:pub (:pub ps)}
                   ax/wrap-reg)
          published (atom [])]
      ((:sub ps) "some/fact" (partial swap! published conj))
      (doseq [_ (range 10)]
        ((:pub host) {:kind :fact :name "some/fact" :key 1 :data [2]}))
      (is (= (count @published) 10)))

7    wrap-late-subs

One of the problems that exist in the pub/sub pattern is the problem of late subscribers. A late subscriber is a subscriber that subscribes to a topic after content has already been published on that topic. In our case this could be a view function that is consulted late in the process, after another view has already requested the data it was interested in. Because we de-duplicate registration, the events are not re-sent to the new subscriber. This results in the new subscriber not having the information it needs.

wrap-late-subs is intended to solve this problem by persisting all incoming events, and repeating relevant ones to new subscribers. It is a connection middleware that augments the :sub method. If no relevant publications have been made before the call to :sub, the callback will receive only new events.

wrap-late-subs-1
    (let [ps (ax/pubsub :name)
          host (-> {:sub (:sub ps)}
                   ax/wrap-late-subs)
          published (atom [])]
      ((:sub host) "some/fact" (constantly nil)) ;; First subscription that causes events to come from the server
      ((:sub host) "some/fact" (partial swap! published conj)) ;; Our subscription
      ((:pub ps) {:kind :fact :name "some/fact" :key 1 :data [2]})
      ((:pub ps) {:kind :fact :name "some/fact" :key 2 :data [3]})
      (is (= @published [{:kind :fact :name "some/fact" :key 1 :data [2]}
                         {:kind :fact :name "some/fact" :key 2 :data [3]}])))

wrap-late-subs makes sure the order between subscribing and publishing is not important. Even if :sub was called after the events have already been :published, they will still be provided to the callback.

wrap-late-subs-2
    (let [ps (ax/pubsub :name)
          host (-> {:sub (:sub ps)}
                   ax/wrap-late-subs)
          published (atom [])]
      ((:sub host) "some/fact" (constantly nil)) ;; First subscription that causes events to come from the server
      ((:pub ps) {:kind :fact :name "some/fact" :key 1 :data [2]})
      ((:pub ps) {:kind :fact :name "some/fact" :key 2 :data [3]})
      ((:sub host) "some/fact" (partial swap! published conj)) ;; Our subscription
      (is (= @published [{:kind :fact :name "some/fact" :key 1 :data [2]}
                         {:kind :fact :name "some/fact" :key 2 :data [3]}])))

8    default-connection

default-connection is a high-level function intended to provide a connection-map using the default settings. It uses ws-url to create a WebSocket URL based on js/document.location, it calls connection to create a connection to that URL, and uses wrap-feed-forward and wrap-atomic-updates to augment this connection to work well with views and queries.

9    Under the Hood

9.1    pubsub

pubsub is a simple synchronous publish/subscribe mechanism. The pubsub function takes a dispatch function as parameter, and returns a map containing two functions: :sub and :pub.

pubsub-1
    (let [ps (ax/pubsub (fn [x]))]
      (is (fn? (:pub ps)))
      (is (fn? (:sub ps))))

When calling the :pub function with a value, the dispatch function is called with that value.

pubsub-2
    (let [val (atom nil)
          ps (ax/pubsub (fn [x]
                          (reset! val x)))]
      ((:pub ps) [1 2 3])
      (is (= @val [1 2 3])))

The :sub function takes a dispatch value, and a function. When the dispatch function returns that dispatch value, the :subscribed function is called with the :published value.

pubsub-3
    (let [val (atom nil)
          ps (ax/pubsub :name)]
      ((:sub ps) "alice" (partial reset! val))
      ((:pub ps) {:name "alice" :age 28})
      ((:pub ps) {:name "bob" :age 31})
      (is (= @val {:name "alice" :age 28})))

10    Testing Utilities

The axiom-cljs library provides some utility functions to help test client-side code. Consider the following code, defining a view and a reagent component for editing tasks.

(defview tasks-view [me]
  [:my-app/task me task ts]
  :order-by ts)

(defn tasks-editor [host]
  (let [tasks (tasks-view host (user host))
        {:keys [add]} (meta tasks)]
    [:div
     [:h2 (str (user host) "'s Tasks")]
     [:ul (for [{:keys [ts task del! swap!]} tasks]
            [:li {:key ts}
             [:input {:value task
                      :on-change #(swap! assoc :task (.-target.value %))}]
             [:button {:on-click del!} "Done"]])]
     [:button {:on-click #(add {:ts ((:time host))
                                :task ""})} "Add Task"]]))

We build this code TDD-style, using the following tests. We use reagent-query for querying the generated UI. We start with an empty task-list. We expect to see a :div containing a :h2 with the user's name.

test1
(let [host (ax/mock-connection "foo")
      ui (tasks-editor host)]
  (is (= (rq/query ui :div :h2) ["foo's Tasks"])))

Now we add two tasks to the view.

(defn add-two-tasks [host]
  (let [{:keys [add]} (meta (tasks-view host "foo"))]
    (add {:ts 1000
          :task "One"})
    (add {:ts 2000
          :task "Two"})))

We expect to see them as :li elements inside a :ul element. Each :li element should have a key attribute that matches the timestamp of a task. Additionally, it should contain an :input box, for which the :value attribute contains the task, and a button with the caption "Done".

test2
(let [host (ax/mock-connection "foo")]
  (add-two-tasks host)
  (let [ui (tasks-editor host)]
    (is (= (rq/query ui :div :ul :li:key) [1000 2000]))
    (is (= (rq/query ui :div :ul :li :input:value) ["One" "Two"]))
    (is (= (rq/query ui :div :ul :li :button) ["Done" "Done"]))))

The button's :on-click handler removes an item from the list.

test3
(let [host (ax/mock-connection "foo")]
  (add-two-tasks host)
  (let [ui (tasks-editor host)
        deleters (rq/query ui :div :ul :li :button:on-click)]
    ;; We click the first button
    ((first deleters))
    ;; We are left with "Two"
    (is (= (rq/query (tasks-editor host) :div :ul :li :input:value) ["Two"]))))

The :input box's :on-change event updates the task.

test4
(let [host (ax/mock-connection "foo")]
  (add-two-tasks host)
  (let [ui (tasks-editor host)
        updaters (rq/query ui :div :ul :li :input:on-change)]
    ;; We edit the second input box
    ((second updaters) (rq/mock-change-event "Three"))
    ;; The "Two" value became "Three"
    (is (= (rq/query (tasks-editor host) :div :ul :li :input:value) ["One" "Three"]))))

A "New Task" button creates a new, empty task.

test5
(let [host (-> (ax/mock-connection "foo")
               (assoc :time (constantly 555)))]
  (let [ui (tasks-editor host)]
    (is (= (rq/query ui :div :button) ["Add Task"]))
    (let [add-task (first (rq/query ui :div :button:on-click))]
      (add-task))
    ;; A new task should appear, with empty text and timestamp of 555
    (let [ui (tasks-editor host)]
      (is (= (rq/query ui :div :ul :li:key) [555]))
      (is (= (rq/query ui :div :ul :li :input:value) [""])))))

10.1    mock-connection

To facilitate tests, mock-connection allows its users to create a host object, similar to the one created by default-connection and connection, but one that does not connect to an actual server.

The mock-connection function takes one argument: the user identity, to be returned by the user macro.

mock-connection1
    (let [host (ax/mock-connection "foo")]
      (is (= (user host) "foo")))

The resulting host map has :pub and :sub members that interact with each other: Publishing on :pub will be seen when subscribing on :sub.

mock-connection2
    (let [host (ax/mock-connection "x")
          {:keys [pub sub]} host
          res (atom nil)]
      (sub "foo" #(reset! res (:data %)))
      (pub {:name "foo" :data 123})
      (is (= @res 123)))

The mock host also has a :time function, implementing a counter.

mock-connection3
    (let [host (ax/mock-connection "x")
          {:keys [time]} host]
      (is (= (time) 0))
      (is (= (time) 1))
      (is (= (time) 2)))

10.2    query-mock

A query involves both a question and answers. To mock the platform's part in a query, we need to:

  1. Register to receive questions,
  2. Validate the question we received against the expected one, and
  3. Provide an answer.

query-mock takes a (mock) host map and a keyword containing the name of the query we wish to mock. It returns an answer function, one that takes the expected inputs and provided outputs for a query.

Consider the following query, with two inputs and two outputs:

(defquery foo-query [x y]
  [:something/foo x y -> z w])

In a production scenario, the query function is called at least twice: Once during the initial creation of the UI, when the results are not yet known, and another time (or times), as results start poring in from the platform.

A test will mimic this behavior by calling the query function twice, either directly (as we do here), or indirectly, by calling the component function. The overall sequence of a typical test is the following:

  1. Create a query mock function associated with the query.
  2. Call the UI component function (which calls the query function), expecting empty result.
  3. Call the query mock function with one or more results, and
  4. Call the UI component function once again to see the effect of the results.

In the following code we replace the component function with calling the view function directly.

query-mock1
    (let [host (ax/mock-connection "x")
          foo-mock (ax/query-mock host :something/foo)]
      ;; It is initially empty
      (is (= (foo-query host 1 2) []))
      ;; Let's add a result
      (foo-mock [1 2] [3 4])
      ;; Now we should see one result
      (is (= (foo-query host 1 2) [{:z 3 :w 4}])))

If the given inputs do not match a query that was made, an exception is thrown.

query-mock2
    (let [host (ax/mock-connection "x")
          foo-mock (ax/query-mock host :something/foo)]
      ;; We call foo-mock with [1 2] without having this question asked before
      (is (thrown-with-msg? js/Error #"Query :something.foo was never executed with inputs .1 2."
                            (foo-mock [1 2] [3 4]))))