Use version numbers instead of timestamps

Also:
- Don't acknowledge changes sent from the server to the client.
- Only send one message at a time to the server.

All of these changes bring this more in line with the differential sync paper.
This commit is contained in:
Nicholas Kariniemi 2014-10-25 15:43:39 +03:00
parent 79e64596ae
commit 5ee40a6471
10 changed files with 224 additions and 223 deletions

View file

@ -56,14 +56,16 @@
:stop-server nil :stop-server nil
:states (atom nil)}) :states (atom nil)})
(defn handle-websocket [handler states new-states] (defn handle-websocket [handler states new-states-pub]
(fn [{:keys [websocket?] :as request}] (fn [{:keys [websocket?] :as request}]
(if websocket? (if websocket?
(httpkit/with-channel request ws-channel (httpkit/with-channel request ws-channel
(let [to-client (chan) (let [to-client (chan)
from-client (chan)] from-client (chan)
new-states (chan (a/sliding-buffer 1))]
(a/sub new-states-pub :new-state new-states)
(ws/add-connected-client! ws-channel to-client from-client) (ws/add-connected-client! ws-channel to-client from-client)
(sync/sync-new-client! to-client from-client new-states states))) (sync/make-server-agent to-client from-client new-states states)))
(handler request)))) (handler request))))
(defn handle-root [handler index] (defn handle-root [handler index]
@ -80,20 +82,21 @@
:body ""} :body ""}
(handler req)))) (handler req))))
(defn make-handler [{:keys [index states]} new-states] (defn make-handler [{:keys [index states]} new-states-pub]
(-> (fn [req] "Not found") (-> (fn [req] "Not found")
(file/wrap-file "public") (file/wrap-file "public")
(content-type/wrap-content-type) (content-type/wrap-content-type)
(handle-root index) (handle-root index)
(handle-websocket states new-states) (handle-websocket states new-states-pub)
(wrap-bounce-favicon))) (wrap-bounce-favicon)))
(defn start [{:keys [port db-name states] :as system}] (defn start [{:keys [port db-name states] :as system}]
(let [{:keys [db conn]} (db/connect db-name) (let [{:keys [db conn]} (db/connect db-name)
new-states (chan) new-states (chan)
new-states-pub (a/pub new-states (fn [_] :new-state))
db-state (db/get-current-state db) db-state (db/get-current-state db)
_ (reset! states (state/new-states (if db-state db-state state/empty-state))) _ (reset! states (state/new-states (if db-state db-state state/empty-state)))
stop-server (httpkit/run-server (make-handler system new-states) {:port port})] stop-server (httpkit/run-server (make-handler system new-states-pub) {:port port})]
(add-watch states :db (fn [_ _ old new] (add-watch states :db (fn [_ _ old new]
(when-not (= old new) (when-not (= old new)
(let [new-state (state/get-latest new)] (let [new-state (state/get-latest new)]

View file

@ -18,8 +18,9 @@
(defn read-msg [msg] (defn read-msg [msg]
(let [in (ByteArrayInputStream. (.getBytes msg)) (let [in (ByteArrayInputStream. (.getBytes msg))
reader (t/reader in :json)] reader (t/reader in :json)
(t/read reader))) received (t/read reader)]
received))
(defn add-connected-client! [ws-channel to from] (defn add-connected-client! [ws-channel to from]
(println "Client connected:" (.toString ws-channel)) (println "Client connected:" (.toString ws-channel))

View file

@ -22,7 +22,8 @@
(send-pending-msg websocket pending-msg)) (send-pending-msg websocket pending-msg))
(defn read-msg [msg] (defn read-msg [msg]
(t/read reader (.-message msg))) (let [received (t/read reader (.-message msg))]
received))
(defn connect [pending-msg in out] (defn connect [pending-msg in out]
(let [ws (goog.net.WebSocket.) (let [ws (goog.net.WebSocket.)

View file

@ -1,7 +1,6 @@
(ns grub.diff (ns grub.diff
(:require [clojure.data :as data] (:require [clojure.data :as data]
[clojure.set :as set] [clojure.set :as set]))
[grub.tag :as tag]))
(defn deleted [a b] (defn deleted [a b]
(set/difference (into #{} (keys a)) (into #{} (keys b)))) (set/difference (into #{} (keys a)) (into #{} (keys b))))
@ -22,27 +21,19 @@
(into {}))) (into {})))
(defn diff-states [prev next] (defn diff-states [prev next]
(let [key-diffs (diff-keys prev next)] (->> prev
(if (and (:tag prev) (:tag next)) (keys)
(assoc key-diffs (map (fn [k] [k (diff-maps (k prev) (k next))]))
:shadow-tag (:tag prev) (filter #(not (nil? (second %))))
:tag (:tag next)) (into {})))
key-diffs)))
(defn patch-map [state diff] (defn patch-map [state diff]
(-> state (-> state
(#(apply dissoc % (into [] (:- diff)))) (#(apply dissoc % (into [] (:- diff))))
(#(merge-with merge % (:+ diff))))) (#(merge-with merge % (:+ diff)))))
(defn patch-state (defn patch-state [state diff]
([state diff] (patch-state state diff false)) (->> state
([state diff use-diff-tag?] (keys)
(let [patched (->> state (map (fn [k] [k (patch-map (k state) (k diff))]))
(keys) (into {})))
(map (fn [k] [k (patch-map (k state) (k diff))]))
(into {}))]
(if use-diff-tag?
(assoc patched :tag (:tag diff))
(if (= state patched)
state
(assoc patched :tag (tag/new-tag)))))))

View file

@ -1,14 +1,13 @@
(ns grub.state (ns grub.state
(:require [grub.diff :as diff] (:require [grub.diff :as diff]
[grub.util :as util] [grub.util :as util]))
[grub.tag :as tag]))
(def num-history-states 20) (def num-history-states 20)
(def empty-state {:tag (tag/oldest-tag) :grubs {} :recipes {}}) (def empty-state {:tag 0 :grubs {} :recipes {}})
(defn new-states [state] (defn new-states [state]
[(assoc state :tag (tag/new-tag))]) [(assoc state :tag 0)])
(defn get-latest [states] (defn get-latest [states]
(last states)) (last states))
@ -22,7 +21,7 @@
(let [last-state (last states)] (let [last-state (last states)]
(if (= last-state new-state) (if (= last-state new-state)
states states
(let [new-states (conj states (assoc new-state :tag (tag/new-tag)))] (let [new-states (conj states (assoc new-state :tag (inc (:tag last-state))))]
(if (>= (count states) num-history-states) (if (>= (count states) num-history-states)
(into [] (rest new-states)) (into [] (rest new-states))
new-states))))) new-states)))))

View file

@ -22,24 +22,27 @@
(defn diff-msg [shadow state] (defn diff-msg [shadow state]
(let [diff (diff/diff-states shadow state)] (let [diff (diff/diff-states shadow state)]
{:type :diff {:type :diff
:diff diff})) :diff diff
:tag (:tag state)
:shadow-tag (:tag shadow)}))
(defmulti handle-event (fn [event] (defmulti handle-event (fn [event] (:type event)))
(:type event)))
(defn apply-diff [states diff shadow] (defn apply-diff [states diff shadow new-shadow-tag client?]
(let [new-states (swap! states update-states diff) (let [new-states (swap! states update-states diff)
new-state (state/get-latest new-states) new-state (state/get-latest new-states)
new-shadow (diff/patch-state shadow diff true)] new-shadow (assoc (diff/patch-state shadow diff)
{:out-event (when-not (state/state= shadow new-state) :tag new-shadow-tag)]
(diff-msg new-shadow new-state)) {:new-shadow new-shadow
:new-states new-states ;; Workaround to send an "ACK" diff when there are no changes
:new-shadow new-shadow})) :out-event (when (and (not client?)
(state/state= new-state new-shadow))
(diff-msg new-shadow new-state))}))
(defmethod handle-event :diff [{:keys [diff states shadow client?]}] (defmethod handle-event :diff [{:keys [diff states shadow shadow-tag tag client?]}]
(let [history-shadow (state/get-tagged @states (:shadow-tag diff))] (let [history-shadow (state/get-tagged @states shadow-tag)]
(if history-shadow (if history-shadow
(apply-diff states diff history-shadow) (apply-diff states diff history-shadow tag client?)
(if client? (if client?
{:out-event full-sync-request {:out-event full-sync-request
:new-shadow shadow} :new-shadow shadow}
@ -56,45 +59,79 @@
(reset! states (state/new-states full-state)) (reset! states (state/new-states full-state))
{:new-shadow full-state}) {:new-shadow full-state})
(defmethod handle-event :new-state [{:keys [shadow states new-state client?]}]
(let [new-states (swap! states state/add new-state)
latest-state (state/get-latest new-states)]
{:out-event (when-not (state/state= shadow latest-state)
(diff-msg shadow latest-state))
:new-shadow (when (and (not client?)
(not (state/state= shadow latest-state)))
(assoc latest-state :tag (inc (:tag shadow))))}))
(defmethod handle-event :default [msg] (defmethod handle-event :default [msg]
#+cljs (logs "Unhandled message:" msg) #+cljs (logs "Unhandled message:" msg)
#+clj (println "Unhandled message:" msg) #+clj (println "Unhandled message:" msg)
{}) {})
(defn make-agent (defn make-server-agent
([client? >remote events new-states states] ([>remote events new-states states]
(make-agent client? >remote events new-states states state/empty-state)) (make-server-agent >remote events new-states states state/empty-state))
([client? >remote events new-states states initial-shadow] ([>remote events new-states states initial-shadow]
(go (loop [shadow initial-shadow] (go (loop [shadow initial-shadow]
(let [[v c] (a/alts! [new-states events] :priority true)] (let [[v c] (a/alts! [new-states events] :priority true)]
(cond (nil? v) nil ;; drop out of loop (cond (nil? v) nil ;; drop out of loop
(= c new-states) (= c new-states)
(do (when-not (state/state= shadow v) (let [event {:type :new-state
(swap! states state/add v) :new-state v
(>! >remote (diff-msg shadow v))) :shadow shadow
(recur shadow)) :states states
:client? false}
{:keys [out-event new-shadow]} (handle-event event)]
(when out-event (a/put! >remote out-event))
(recur (if new-shadow new-shadow shadow)))
(= c events) (= c events)
(let [event (assoc v (let [event (assoc v
:states states :states states
:client? client? :client? false
:shadow shadow) :shadow shadow)
{:keys [new-shadow out-event]} (handle-event event)] {:keys [new-shadow out-event]} (handle-event event)]
(when out-event (a/put! >remote out-event)) (when out-event (a/put! >remote out-event))
(recur (if new-shadow new-shadow shadow))))))))) (recur (if new-shadow new-shadow shadow)))))))))
(def make-server-agent (partial make-agent false)) (defn make-client-agent
(def make-client-agent (partial make-agent true)) ([>remote events new-states states]
(make-client-agent >remote events new-states states state/empty-state))
#+clj ([>remote events new-states states initial-shadow]
(defn sync-new-client! [>remote events new-states states] (go (loop [shadow initial-shadow
(make-server-agent >remote events new-states states)) out-event nil]
(when out-event (>! >remote out-event))
(let [timeout (a/timeout 1000)
[v c] (if out-event
(a/alts! [events timeout])
(a/alts! [new-states events] :priority true))]
(cond (= c timeout) (recur shadow out-event)
(nil? v) nil ;; drop out of loop
(= c new-states)
(let [event {:type :new-state
:new-state v
:shadow shadow
:states states
:client? true}
{:keys [out-event]} (handle-event event)]
(recur shadow out-event))
(= c events)
(let [event (assoc v
:states states
:client? true
:shadow shadow)
{:keys [new-shadow out-event]} (handle-event event)]
(recur (if new-shadow new-shadow shadow) out-event))))))))
#+cljs #+cljs
(defn sync-client! [>remote events new-states states] (defn sync-client! [>remote events new-states states]
(let [new-states* (chan)] (let [new-states* (chan (a/sliding-buffer 1))]
(go (loop [] (go (loop []
(let [v (<! new-states)] (let [v (<! new-states)]
(<! (a/timeout 1000))
(>! new-states* v) (>! new-states* v)
(recur)))) (recur))))
(make-client-agent >remote events new-states* states) (make-client-agent >remote events new-states* states)

View file

@ -1,9 +0,0 @@
(ns grub.tag)
(defn new-tag []
#+clj (java.util.Date.)
#+cljs (js/Date.))
(defn oldest-tag []
#+clj (java.util.Date. 0)
#+cljs (js/Date. 0))

View file

@ -130,37 +130,6 @@
(fact "Diff of many changes has all changes" (fact "Diff of many changes has all changes"
(diff/diff-states before-state after-state) => expected-diff) (diff/diff-states before-state after-state) => expected-diff)
(fact "Diff and patch of many changes returns original state with new tag" (fact "Diff and patch of many changes returns original state"
(let [diff (diff/diff-states before-state after-state) (let [diff (diff/diff-states before-state after-state)]
result (diff/patch-state before-state diff)] (diff/patch-state before-state diff) => after-state))
(dissoc result :tag) => after-state
(:tag result) => #(not (nil? %))))
(fact "Diff of states with tags includes tags in diff"
(diff/diff-states {:tag "1"} {:tag "2"}) => {:tag "2" :shadow-tag "1"})
(fact "Patch of state creates new tag by default"
(let [result (diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0} {:grubs {:+ {:a {:b1 :b3}}} :- #{}})]
result => (contains {:grubs {:a {:b1 :b3}}})
(:tag result) => #(not (nil? %))
(:tag result) => #(not= % 0)))
(fact "Patch of state sets new tag to patch tag if specified"
(diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0}
{:grubs {:+ {:a {:b1 :b3}}} :- #{} :tag 4}
true)
=>
{:grubs {:a {:b1 :b3}} :tag 4})
(fact "Empty patch of state sets new tag to patch tag if specified"
(diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0}
{:shadow-tag 0 :tag 4 :grubs {:+ nil :- #{}}}
true)
=>
{:grubs {:a {:b1 :b2}} :tag 4})
(fact "Patch of empty diff returns original state"
(diff/patch-state {:grubs {:a {:b1 :b2}} :tag 0}
{:grubs {:+ nil :- #{}} :tag 4})
=>
{:grubs {:a {:b1 :b2}} :tag 0})

View file

@ -3,34 +3,33 @@
[midje.sweet :refer :all])) [midje.sweet :refer :all]))
(fact "Get current state returns last state" (fact "Get current state returns last state"
(let [states [{:tag "1" :a :b} (let [states [{:tag 1 :a :b}
{:tag "2" :c :d}]] {:tag 2 :c :d}]]
(s/get-latest states) => {:tag "2" :c :d})) (s/get-latest states) => {:tag 2 :c :d}))
(fact "Get history state returns state with given hash" (fact "Get history state returns state with given hash"
(let [states [{:tag "1" :a :b} (let [states [{:tag 1 :a :b}
{:tag "2" :c :d} {:tag 2 :c :d}
{:tag "3" :e :f}]] {:tag 3 :e :f}]]
(s/get-tagged states "1") => {:tag "1" :a :b} (s/get-tagged states 1) => {:tag 1 :a :b}
(s/get-tagged states "2") => {:tag "2" :c :d} (s/get-tagged states 2) => {:tag 2 :c :d}
(s/get-tagged states "3") => {:tag "3" :e :f})) (s/get-tagged states 3) => {:tag 3 :e :f}))
(fact "Add history state appends state to the end" (fact "Add history state appends state to the end and increments tag"
(let [states [{:tag "1" :a :b} (let [states [{:tag 1 :a :b}
{:tag "2" :c :d}]] {:tag 2 :c :d}]]
(-> (s/add states {:e :f}) (s/add states {:e :f}) => [{:tag 1 :a :b}
(last) {:tag 2 :c :d}
(dissoc :tag)) {:tag 3 :e :f}]))
=> {:e :f}))
(fact "Add history state appends state to the end and drops first state if full" (fact "Add history state appends state to the end and drops first state if full"
(let [states (into [] (for [i (range 20)] {:tag (str i) :i i})) (let [states (into [] (for [i (range 20)] {:tag i :i i}))
new-states (s/add states {:i 21})] new-states (s/add states {:i 21})]
(count new-states) => 20 (count new-states) => 20
(dissoc (last new-states) :tag) => {:i 21} (dissoc (last new-states) :tag) => {:i 21}
(first new-states) => {:tag "1" :i 1})) (first new-states) => {:tag 1 :i 1}))
(fact "Add history state does not add consecutive duplicate states" (fact "Add history state does not add consecutive duplicate states"
(let [states [{:tag "1" :a :b} (let [states [{:tag 1 :a :b}
{:tag "2" :c :d}]] {:tag 2 :c :d}]]
(s/add states {:tag "2" :c :d}) => states)) (s/add states {:tag 2 :c :d}) => states))

View file

@ -3,150 +3,159 @@
[grub.sync :as sync] [grub.sync :as sync]
[midje.sweet :refer :all])) [midje.sweet :refer :all]))
(facts "Server diff" (facts "Server"
(fact "Server applies diff, returns empty diff with client tag, new server tag when no server changes" (fact "Diff, no server changes - Apply diff, return empty diff"
(let [{:keys [new-states out-event new-shadow]} (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
{:keys [out-event new-shadow]}
(sync/handle-event (sync/handle-event
{:type :diff {:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}} :tag 4
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :shadow-tag 0
:diff {:grubs {:+ {"1" {:completed true}} :- #{}}}
:states states
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:client? false})] :client? false})]
new-states => (just (just {:tag 0 @states => (just (just {:tag 0
:grubs {"1" {:completed false, :text "2 apples"}} :grubs {"1" {:completed false, :text "2 apples"}}
:recipes {}}) :recipes {}})
(just {:tag #(not (nil? %)) (just {:tag 1
:grubs {"1" {:completed true, :text "2 apples"}} :grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}})) :recipes {}}))
out-event => (just {:type :diff out-event => (just {:type :diff
:diff (just {:shadow-tag 4 :diff {:grubs {:- #{} :+ nil} :recipes {:- #{}, :+ nil}}
:tag #(not (nil? %)) :shadow-tag 4
:grubs {:- #{} :+ nil} :tag 1})
:recipes {:- #{}, :+ nil}})})
new-shadow => {:tag 4 new-shadow => {:tag 4
:grubs {"1" {:completed true, :text "2 apples"}} :grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}})) :recipes {}}))
(fact "Server applies diff and returns changes when server has changed" (fact "Diff, server changes - Apply diff, return changes"
(let [{:keys [new-states new-shadow out-event]} (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:tag 1 :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}])
{:keys [new-shadow out-event]}
(sync/handle-event (sync/handle-event
{:type :diff {:type :diff
:diff {:shadow-tag 0 :tag 4 :shadow-tag 0
:grubs {:+ {"1" {:completed true}} :- #{}}} :tag 4
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :diff {:grubs {:+ {"1" {:completed true}} :- #{}}}
{:tag 1 :grubs {"1" {:text "2 apples" :completed false} :states states
"2" {:text "3 onions" :completed false}}
:recipes {}}])
:shadow state/empty-state :shadow state/empty-state
:client? false})] :client? false})]
new-states => @states =>
(just {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} (just {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:tag 1 :grubs {"1" {:text "2 apples" :completed false} {:tag 1 :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}} "2" {:text "3 onions" :completed false}} :recipes {}}
:recipes {}} {:tag 2 :grubs {"1" {:text "2 apples" :completed true}
(just {:tag #(not (nil? %)) :grubs {"1" {:text "2 apples" :completed true} "2" {:text "3 onions" :completed false}} :recipes {}})
"2" {:text "3 onions" :completed false}}
:recipes {}}))
out-event => out-event =>
(just {:type :diff (just {:type :diff
:diff (just {:shadow-tag 4 :shadow-tag 4
:tag #(not (nil? %)) :tag 2
:grubs {:- #{} :+ {"2" {:completed false, :text "3 onions"}}} :diff {:grubs {:- #{} :+ {"2" {:completed false, :text "3 onions"}}}
:recipes {:- #{}, :+ nil}})}) :recipes {:- #{}, :+ nil}}})
new-shadow => {:tag 4 new-shadow => {:tag 4
:grubs {"1" {:text "2 apples" :completed true}} :grubs {"1" {:text "2 apples" :completed true}}
:recipes {}})) :recipes {}}))
(fact "Server forces full sync if client is out of sync" (fact "Diff, client out of sync - Force full sync"
(let [states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} (let [states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:tag 15 :grubs {"1" {:text "2 apples" :completed false} {:tag 15 :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}} "2" {:text "3 onions" :completed false}}
:recipes {}}]) :recipes {}}])
event {:type :diff event {:type :diff
:diff {:shadow-tag 3 :tag 12 :shadow-tag 3
:grubs {:updated {"0" {:completed true}} :deleted #{}}} :tag 12
:diff {:grubs {:updated {"0" {:completed true}} :deleted #{}}}
:states states :states states
:shadow state/empty-state :shadow state/empty-state
:client? false} :client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)] {:keys [new-shadow out-event]} (sync/handle-event event)]
new-states => nil
out-event => {:type :full-sync out-event => {:type :full-sync
:full-state {:tag 15 :full-state {:tag 15
:grubs {"1" {:text "2 apples" :completed false} :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}} "2" {:text "3 onions" :completed false}}
:recipes {}}})) :recipes {}}}))
(fact "Server state is unchanged on receiving empty diff" (fact "New state - Update state, send diff, update shadow assuming diff received"
(-> (sync/handle-event (let [states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
{:type :diff event {:type :new-state
:diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} :states states
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :shadow {:tag 3 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :client? false
:client? false}) :new-state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}
:new-states) {:keys [new-shadow out-event]} (sync/handle-event event)]
=> [{:tag 0 @states => [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:grubs {"1" {:completed false, :text "2 apples"}} {:tag 15 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}]
:recipes {}}]) new-shadow => {:tag 4 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}
out-event => {:type :diff
:shadow-tag 3
:tag 15
:diff {:grubs {:+ {"1" {:completed true}} :- #{}}
:recipes {:+ nil :- #{}}}}))
(fact "Server returns no response on empty diff" (fact "Server sends full sync if client requests it"
(-> (sync/handle-event (let [result (sync/handle-event
{:type :diff {:type :full-sync-request
:diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} :states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) {:tag 15 :grubs {"1" {:text "2 apples" :completed false}
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} "2" {:text "3 onions" :completed false}}
:client? false}) :recipes {}}])})]
:out-event) (:new-shadow result) =>
=> nil) (just {:tag #(not (nil? %))
:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}})
(:out-event result) =>
{:type :full-sync
:full-state {:tag 15
:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}}))
)
(fact "Server updates client shadow on empty diff"
(-> (sync/handle-event
{:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}}
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:client? false})
:new-shadow)
=> {:tag 4 :grubs {"1" {:completed false, :text "2 apples"}} :recipes {}}))
(facts "Client diffs" (facts "Client diffs"
(fact "Client applies diff, returns empty diff with server tag, new client tag when no client changes" (fact "Client applies diff, does not return diff when no client changes"
(let [event {:type :diff (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
:diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}} event {:type :diff
:states (atom :shadow-tag 0
[{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :tag 4
:recipes {}}]) :diff {:grubs {:+ {"1" {:completed true}} :- #{}}}
:states states
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}}
:recipes {}} :recipes {}}
:client? true} :client? true}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)] {:keys [new-shadow out-event]} (sync/handle-event event)]
new-states => @states =>
(just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}} (just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
(just {:tag #(not (nil? %)) {:tag 1
:grubs {"1" {:completed true, :text "2 apples"}} :grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}})) :recipes {}})
new-shadow {:tag 4 :grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}} new-shadow {:tag 4 :grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}
out-event => (just {:type :diff out-event => nil))
:diff (just {:shadow-tag 4
:tag #(not (nil? %))
:grubs {:- #{} :+ nil}
:recipes {:- #{}, :+ nil}})})))
(fact "Client state is unchanged on receiving empty diff" (fact "Client state is unchanged on receiving empty diff"
(-> (sync/handle-event (let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])]
{:type :diff (sync/handle-event
:diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} {:type :diff
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :shadow-tag 0
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :tag 4
:client? true}) :diff {:grubs {:+ nil :- #{}}}
:new-states) :states states
=> [{:tag 0 :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:grubs {"1" {:completed false, :text "2 apples"}} :client? true})
:recipes {}}]) @states => [{:tag 0
:grubs {"1" {:completed false, :text "2 apples"}}
:recipes {}}]))
(fact "Client returns no response on empty diff" (fact "Client returns no response on empty diff"
(-> (sync/handle-event (-> (sync/handle-event
{:type :diff {:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} :shadow-tag 0
:tag 4
:diff {:grubs {:+ nil :- #{}}}
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:client? true}) :client? true})
@ -156,7 +165,9 @@
(fact "Client updates server shadow on empty diff" (fact "Client updates server shadow on empty diff"
(-> (sync/handle-event (-> (sync/handle-event
{:type :diff {:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ nil :- #{}}} :shadow-tag 0
:tag 4
:diff {:grubs {:+ nil :- #{}}}
:states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}]) :states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}} :shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
:client? true}) :client? true})
@ -171,7 +182,6 @@
{:tag 15 :grubs {"1" {:text "2 apples" :completed false} {:tag 15 :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}} "2" {:text "3 onions" :completed false}}
:recipes {}}])})] :recipes {}}])})]
(:new-states result) => nil
(:new-shadow result) => (:new-shadow result) =>
(just {:tag #(not (nil? %)) (just {:tag #(not (nil? %))
:grubs {"1" {:text "2 apples" :completed false} :grubs {"1" {:text "2 apples" :completed false}