Use timestamp tags instead of hashes

- Hashing was slow and we weren't using the hash properties much anyway
This commit is contained in:
Nicholas Kariniemi 2014-10-19 21:42:49 +03:00
parent 19ca650078
commit 8fe22b9a52
13 changed files with 385 additions and 269 deletions

View file

@ -18,7 +18,8 @@
(defn start
"Starts the current development system."
[]
(alter-var-root #'system system/start system/dev-system))
(alter-var-root #'system (constantly system/dev-system))
(alter-var-root #'system system/start))
(defn stop
"Shuts down and destroys the current development system."

View file

@ -17,7 +17,6 @@
[clj-webdriver "0.6.1" :exclusions [org.clojure/core.cache]]
[sablono "0.2.17"]
[cljs-uuid "0.0.4"]
[net.polyc0l0r/hasch "0.2.3"]
[com.cognitect/transit-clj "0.8.259"]
[com.cognitect/transit-cljs "0.8.188"]]
:profiles {:uberjar {:aot :all}

View file

@ -92,11 +92,11 @@
(let [{:keys [db conn]} (db/connect db-name)
new-states (chan)
db-state (db/get-current-state db)
_ (reset! states (state/new-state (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})]
(add-watch states :db (fn [_ _ old new]
(when-not (= old new)
(let [new-state (state/get-current-state new)]
(let [new-state (state/get-latest new)]
(a/put! new-states new-state)
(db/update-db! db new-state)))))
(println "Started server on localhost:" port)
@ -106,6 +106,7 @@
:stop-server stop-server
:states states)))
(defn stop [{:keys [db-conn stop-server states] :as system}]
(remove-watch states :db)
(stop-server)

View file

@ -27,7 +27,7 @@
agent-states (sync/sync-client! >remote events new-states states)]
(add-watch states :render (fn [_ _ old new]
(when-not (= old new)
(a/put! render-states (state/get-current-state new)))))
(a/put! render-states (state/get-latest new)))))
(assoc system
:ws ws
:channels {:new-states new-states

View file

@ -1,6 +1,7 @@
(ns grub.diff
(:require [clojure.data :as data]
[clojure.set :as set]))
[clojure.set :as set]
[grub.tag :as tag]))
(defn deleted [a b]
(set/difference (into #{} (keys a)) (into #{} (keys b))))
@ -9,22 +10,39 @@
(second (data/diff a b)))
(defn diff-maps [a b]
{:deleted (deleted a b)
:updated (updated a b)})
(when (and (map? a) (map? b))
{:- (deleted a b)
:+ (updated a b)}))
(defn diff-states [prev next]
(defn diff-keys [prev next]
(->> prev
(keys)
(map (fn [k] [k (diff-maps (k prev) (k next))]))
(filter #(not (nil? (second %))))
(into {})))
(defn diff-states [prev next]
(let [key-diffs (diff-keys prev next)]
(if (and (:tag prev) (:tag next))
(assoc key-diffs
:shadow-tag (:tag prev)
:tag (:tag next))
key-diffs)))
(defn patch-map [state diff]
(-> state
(#(apply dissoc % (into [] (:deleted diff))))
(#(merge-with merge % (:updated diff)))))
(#(apply dissoc % (into [] (:- diff))))
(#(merge-with merge % (:+ diff)))))
(defn patch-state [state diff]
(->> state
(keys)
(map (fn [k] [k (patch-map (k state) (k diff))]))
(into {})))
(defn patch-state
([state diff] (patch-state state diff false))
([state diff use-diff-tag?]
(let [patched (->> state
(keys)
(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,12 +0,0 @@
(ns grub.message)
(def full-sync-request {:type :full-sync-request})
(defn full-sync [state]
{:type :full-sync
:full-state state})
(defn diff-msg [diff hash]
{:type :diff
:diff diff
:hash hash})

View file

@ -1,32 +1,31 @@
(ns grub.state
(:require [grub.diff :as diff]
[grub.util :as util]
[hasch.core :as hasch]))
[grub.tag :as tag]))
(def num-history-states 20)
(def empty-state {:grubs {} :recipes {}})
(def empty-states [{:grubs {} :recipes {}}])
(def empty-state {:tag (tag/oldest-tag) :grubs {} :recipes {}})
(defn new-state [state]
[{:hash (hasch/uuid state)
:state state}])
(defn new-states [state]
[(assoc state :tag (tag/new-tag))])
(defn get-current-state [states]
(:state (last states)))
(defn get-latest [states]
(last states))
(defn get-history-state [states hash]
(:state (first (filter #(= (:hash %) hash) states))))
(defn get-tagged [states tag]
(->> states
(filter #(= (:tag %) tag))
(first)))
(defn add-history-state [states new-state]
(let [last-hash (:hash (last states))
new-hash (hasch/uuid new-state)]
(if (= last-hash new-hash)
(defn add [states new-state]
(let [last-state (last states)]
(if (= last-state new-state)
states
(let [new-states (conj states {:hash new-hash :state new-state})]
(let [new-states (conj states (assoc new-state :tag (tag/new-tag)))]
(if (>= (count states) num-history-states)
(into [] (rest new-states))
new-states)))))
(defn empty-diff? [diff]
(= diff {:recipes {:deleted #{}, :updated nil}, :grubs {:deleted #{}, :updated nil}}))
(defn state= [a b]
(= (dissoc a :tag) (dissoc b :tag)))

View file

@ -1,51 +1,55 @@
(ns grub.sync
(:require [grub.diff :as diff]
[grub.message :as message]
[grub.state :as state]
[hasch.core :as hasch]
#+clj [clojure.core.async :as a :refer [<! >! chan go]]
#+cljs [cljs.core.async :as a :refer [<! >! chan]])
#+cljs (:require-macros [grub.macros :refer [log logs]]
[cljs.core.async.macros :refer [go]]))
(def full-sync-request {:type :full-sync-request})
(defn full-sync [state]
{:type :full-sync
:full-state state})
(def empty-state state/empty-state)
(defn update-states [states diff]
(let [state (state/get-current-state states)
(let [state (state/get-latest states)
new-state (diff/patch-state state diff)]
(state/add-history-state states new-state)))
(state/add states new-state)))
(defn diff-msg [shadow state]
(let [diff (diff/diff-states shadow state)
hash (hasch/uuid shadow)]
(message/diff-msg diff hash)))
(let [diff (diff/diff-states shadow state)]
{:type :diff
:diff diff}))
(defmulti handle-event (fn [event] (:type event)))
(defmethod handle-event :diff [{:keys [hash diff states shadow client?]}]
(let [history-shadow (state/get-history-state @states hash)]
(defmethod handle-event :diff [{:keys [diff states shadow client?]}]
(let [history-shadow (state/get-tagged @states (:shadow-tag diff))]
(if history-shadow
(let [new-states (swap! states update-states diff)
new-state (state/get-current-state new-states)
new-shadow (diff/patch-state history-shadow diff)]
{:out-event (when-not (state/empty-diff? diff)
new-state (state/get-latest new-states)
new-shadow (diff/patch-state history-shadow diff true)]
{:out-event (when-not (state/state= history-shadow new-state)
(diff-msg new-shadow new-state))
:new-states new-states
:new-shadow new-shadow})
(if client?
{:out-event message/full-sync-request
{:out-event full-sync-request
:new-shadow shadow}
(let [state (state/get-current-state @states)]
{:out-event (message/full-sync state)
(let [state (state/get-latest @states)]
{:out-event (full-sync state)
:new-shadow state})))))
(defmethod handle-event :full-sync-request [{:keys [states]}]
(let [state (state/get-current-state @states)]
(let [state (state/get-latest @states)]
{:new-shadow state
:out-event (message/full-sync state)}))
:out-event (full-sync state)}))
(defmethod handle-event :full-sync [{:keys [full-state states]}]
(reset! states (state/new-state full-state))
(reset! states (state/new-states full-state))
{:new-shadow full-state})
(defmethod handle-event :default [msg]
@ -61,8 +65,8 @@
(let [[v c] (a/alts! [new-states events] :priority true)]
(cond (nil? v) nil ;; drop out of loop
(= c new-states)
(do (when-not (= shadow v)
(swap! states state/add-history-state v)
(do (when-not (state/state= shadow v)
(swap! states state/add v)
(>! >remote (diff-msg shadow v)))
(recur shadow))
(= c events)
@ -90,4 +94,4 @@
(>! new-states* v)
(recur))))
(make-client-agent >remote events new-states* states)
(a/put! >remote message/full-sync-request)))
(a/put! >remote full-sync-request)))

9
src/cljx/grub/tag.cljx Normal file
View file

@ -0,0 +1,9 @@
(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

@ -2,18 +2,8 @@
(:require [grub.sync :as sync]
[clojure.test :refer :all]
[midje.sweet :refer :all]
[hasch.core :as hasch]
[clojure.core.async :as a :refer [<!! >!! chan go]]))
(defn hashed-states [& states]
(->> states
(map (fn [s] {:hash (hasch/uuid s)
:state s}))
(into [])))
(defn states-atom [& states]
(atom (apply hashed-states states)))
(defn <!!? [c]
(let [[v p] (a/alts!! [c (a/timeout 100)])]
v))

View file

@ -1,73 +1,76 @@
(ns grub.test.unit.diff
(:require [grub.diff :as diff]
[clojure.test :refer :all]))
[midje.sweet :refer :all]))
(def empty-diff {:grubs {:deleted #{} :updated nil}
:recipes {:deleted #{} :updated nil}})
(def empty-diff {:grubs {:- #{} :+ nil}
:recipes {:- #{} :+ nil}})
(deftest diff-empty-states
(fact "Diff of empty states is empty diff"
(let [empty-state {:grubs {} :recipes {}}]
(is (= empty-diff
(diff/diff-states empty-state empty-state)))))
(diff/diff-states empty-state empty-state) => empty-diff))
(deftest diff-equal-states
(is (= empty-diff
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
(fact "Diff of equal states is empty diff"
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}})
=> empty-diff)
(deftest diff-added-grub
(is (= {:grubs {:deleted #{}
:updated {"id" {:completed false, :text "asdf"}}}
:recipes {:deleted #{} :updated nil}}
(diff/diff-states {:grubs {} :recipes {}}
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
(fact "Diff of one added grub has one updated grub"
(diff/diff-states {:grubs {} :recipes {}}
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}})
=> {:grubs {:- #{}
:+ {"id" {:completed false, :text "asdf"}}}
:recipes {:- #{} :+ nil}})
(deftest diff-deleted-grub
(is (= {:grubs {:deleted #{"id"}
:updated nil}
:recipes {:deleted #{} :updated nil}}
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {} :recipes {}}))))
(fact "Diff of one removed grub has one deleted grub"
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {} :recipes {}})
=>
{:grubs {:- #{"id"}
:+ nil}
:recipes {:- #{} :+ nil}})
(deftest diff-edited-grub
(is (= {:grubs {:deleted #{}
:updated {"id" {:text "asdf2"}}}
:recipes {:deleted #{} :updated nil}}
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf2" :completed false}} :recipes {}}))))
(fact "Diff of one changed grub has updated grub"
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf2" :completed false}} :recipes {}})
=>
{:grubs {:- #{}
:+ {"id" {:text "asdf2"}}}
:recipes {:- #{} :+ nil}})
(deftest diff-completed-grub
(is (= {:grubs {:deleted #{}
:updated {"id" {:completed true}}}
:recipes {:deleted #{} :updated nil}}
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf" :completed true}} :recipes {}}))))
(fact "Diff of one completed grub has updated grub"
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
{:grubs {"id" {:text "asdf" :completed true}} :recipes {}})
=> {:grubs {:- #{}
:+ {"id" {:completed true}}}
:recipes {:- #{} :+ nil}})
(deftest diff-added-recipe
(is (= {:grubs {:deleted #{}
:updated nil}
:recipes {:deleted #{} :updated {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}}
(diff/diff-states {:grubs {} :recipes {}}
{:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}))))
(fact "Diff of one added recipe has updated recipe"
(diff/diff-states {:grubs {} :recipes {}}
{:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}})
=>
{:grubs {:- #{}
:+ nil}
:recipes {:- #{} :+ {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}})
(deftest diff-edited-recipe
(is (= {:grubs {:deleted #{}
:updated nil}
:recipes {:deleted #{} :updated {"id" {:name "Bleu Cheese Soup" }}}}
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}
{:grubs {} :recipes {"id" {:name "Bleu Cheese Soup"
:grubs "Some grubs"}}}))))
(fact "Diff of one changed recipe has one updated recipe"
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}
{:grubs {} :recipes {"id" {:name "Bleu Cheese Soup"
:grubs "Some grubs"}}})
=> {:grubs {:- #{}
:+ nil}
:recipes {:- #{} :+ {"id" {:name "Bleu Cheese Soup" }}}})
(deftest diff-deleted-recipe
(is (= {:grubs {:deleted #{} :updated nil}
:recipes {:deleted #{"id"} :updated nil}}
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}
{:grubs {} :recipes {}}))))
(fact "Diff of one removed recipe has one deleted recipe"
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
:grubs "Some grubs"}}}
{:grubs {} :recipes {}})
=>
{:grubs {:- #{} :+ nil}
:recipes {:- #{"id"} :+ nil}})
(def before-state
{:grubs
@ -107,8 +110,8 @@
(def expected-diff
{:recipes
{:deleted #{"recipe-deleted"}
:updated
{:- #{"recipe-deleted"}
:+
{"recipe-added"
{:name "Burgers"
:grubs
@ -117,17 +120,47 @@
{:grubs
"300 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n400 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n2 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"}}}
:grubs
{:deleted #{"grub-deleted"}
:updated
{:- #{"grub-deleted"}
:+
{"grub-completed" {:completed true}
"grub-updated" {:text "Ketchup"}
"grub-added"
{:completed false :text "Toothpaste"}}}})
(deftest diff-many-changes
(is (= expected-diff (diff/diff-states before-state after-state))))
(fact "Diff of many changes has all changes"
(diff/diff-states before-state after-state) => expected-diff)
(deftest patch-returns-original-state
(is
(let [diff (diff/diff-states before-state after-state)]
(= after-state (diff/patch-state before-state diff)))))
(fact "Diff and patch of many changes returns original state with new tag"
(let [diff (diff/diff-states before-state after-state)
result (diff/patch-state before-state diff)]
(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

@ -1,35 +1,36 @@
(ns grub.test.unit.state
(:require [grub.state :as s]
[midje.sweet :refer :all]
[hasch.core :as hasch]))
[midje.sweet :refer :all]))
(fact "Get current state returns last state"
(let [states [{:hash "asdf" :state {:a :b}}
{:hash "fdsa" :state {:c :d}}]]
(s/get-current-state states) => {:c :d}))
(let [states [{:tag "1" :a :b}
{:tag "2" :c :d}]]
(s/get-latest states) => {:tag "2" :c :d}))
(fact "Get history state returns state with given hash"
(let [states [{:hash "hash1" :state {:a :b}}
{:hash "hash2" :state {:c :d}}
{:hash "hash3" :state {:e :f}}]]
(s/get-history-state states "hash1") => {:a :b}
(s/get-history-state states "hash2") => {:c :d}
(s/get-history-state states "hash3") => {:e :f}))
(let [states [{:tag "1" :a :b}
{:tag "2" :c :d}
{:tag "3" :e :f}]]
(s/get-tagged states "1") => {:tag "1" :a :b}
(s/get-tagged states "2") => {:tag "2" :c :d}
(s/get-tagged states "3") => {:tag "3" :e :f}))
(fact "Add history state appends state to the end"
(let [states [{:hash "hash1" :state {:a :b}}
{:hash "hash2" :state {:c :d}}]]
(:state (last (s/add-history-state states {:e :f}))) => {:e :f}))
(let [states [{:tag "1" :a :b}
{:tag "2" :c :d}]]
(-> (s/add states {:e :f})
(last)
(dissoc :tag))
=> {:e :f}))
(fact "Add history state appends state to the end and drops first state if full"
(let [states (into [] (for [i (range 20)] {:hash (str "hash" i) :state {:i i}}))
new-states (s/add-history-state states {:i 21})]
(let [states (into [] (for [i (range 20)] {:tag (str i) :i i}))
new-states (s/add states {:i 21})]
(count new-states) => 20
(:state (last new-states)) => {:i 21}
(:state (first new-states)) => {:i 1}))
(dissoc (last new-states) :tag) => {:i 21}
(first new-states) => {:tag "1" :i 1}))
(fact "Add history state does not add consecutive duplicate states"
(let [hash (hasch/uuid {:c :d})
states [{:hash "hash1" :state {:a :b}}
{:hash hash :state {:c :d}}]]
(s/add-history-state states {:c :d}) => states))
(let [states [{:tag "1" :a :b}
{:tag "2" :c :d}]]
(s/add states {:tag "2" :c :d}) => states))

View file

@ -1,112 +1,185 @@
(ns grub.test.unit.sync
(:require [grub.state :as state]
[grub.sync :as sync]
[midje.sweet :refer :all]
[hasch.core :as hasch]))
[midje.sweet :refer :all]))
(defn hashed-states [& states]
(->> states
(map (fn [s] {:hash (hasch/uuid s)
:state s}))
(into [])))
(facts "Server diff"
(fact "Server applies diff, returns empty diff with client tag, new server tag when no server changes"
(let [{:keys [new-states out-event new-shadow]}
(sync/handle-event
{:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}}
: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-states => (just (just {:tag 0
:grubs {"1" {:completed false, :text "2 apples"}}
:recipes {}})
(just {:tag #(not (nil? %))
:grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}}))
out-event => (just {:type :diff
:diff (just {:shadow-tag 4
:tag #(not (nil? %))
:grubs {:- #{} :+ nil}
:recipes {:- #{}, :+ nil}})})
new-shadow => {:tag 4
:grubs {"1" {:completed true, :text "2 apples"}}
:recipes {}}))
(fact "Server applies diff and returns empty diff when no server changes"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))
:states states
:shadow (:state (last @states))
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => (hashed-states
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}})
new-shadow {:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}
out-event => {:type :diff
:diff {:grubs {:deleted #{}, :updated nil}
:recipes {:deleted #{}, :updated nil}}
:hash (:hash (last new-states))}))
(fact "Client applies diff and returns empty diff when no client changes"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))
:states states
:shadow (:state (last @states))
:client? true}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => (hashed-states
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}})
new-shadow => {:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}
out-event => {:type :diff
:diff {:grubs {:deleted #{}, :updated nil}
:recipes {:deleted #{}, :updated nil}}
:hash (:hash (last new-states))}))
(fact "Server applies diff and returns changes when server has changed"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
:hash (:hash (first @states))
:states states
:shadow state/empty-state
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
(fact "Server applies diff and returns changes when server has changed"
(let [{:keys [new-states new-shadow out-event]}
(sync/handle-event
{:type :diff
:diff {:shadow-tag 0 :tag 4
:grubs {:+ {"1" {:completed true}} :- #{}}}
: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 {}}])
:shadow state/empty-state
:client? false})]
new-states =>
(just {: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 {}}
{:grubs {"1" {:text "2 apples" :completed true}
:recipes {}}
(just {:tag #(not (nil? %)) :grubs {"1" {:text "2 apples" :completed true}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
out-event =>
(just {:type :diff
:diff (just {:shadow-tag 4
:tag #(not (nil? %))
:grubs {:- #{} :+ {"2" {:completed false, :text "3 onions"}}}
:recipes {:- #{}, :+ nil}})})
new-shadow => {:tag 4
:grubs {"1" {:text "2 apples" :completed true}}
:recipes {}}))
(fact "Server forces full sync if client is out of sync"
(let [states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:tag 15 :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}])
event {:type :diff
:diff {:shadow-tag 3 :tag 12
:grubs {:updated {"0" {:completed true}} :deleted #{}}}
:states states
:shadow state/empty-state
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => nil
out-event => {:type :full-sync
:full-state {:tag 15
:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}}))
(fact "Server state is unchanged on receiving 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-states)
=> [{:tag 0
:grubs {"1" {:completed false, :text "2 apples"}}
:recipes {}}])
(fact "Server returns no response 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})
:out-event)
=> nil)
(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"
(fact "Client applies diff, returns empty diff with server tag, new client tag when no client changes"
(let [event {:type :diff
:diff {:tag 4 :shadow-tag 0 :grubs {:+ {"1" {:completed true}} :- #{}}}
:states (atom
[{:tag 0 :grubs {"1" {:text "2 apples" :completed false}}
:recipes {}}])
:shadow {:tag 0 :grubs {"1" {:text "2 apples" :completed false}}
:recipes {}}
:client? true}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states =>
(just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
(just {:tag #(not (nil? %))
: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
:diff (just {:shadow-tag 4
:tag #(not (nil? %))
:grubs {:- #{} :+ nil}
:recipes {:- #{}, :+ nil}})})))
(fact "Client state is unchanged on receiving 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? true})
:new-states)
=> [{:tag 0
:grubs {"1" {:completed false, :text "2 apples"}}
:recipes {}}])
(fact "Client returns no response 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? true})
:out-event)
=> nil)
(fact "Client updates server 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? true})
:new-shadow)
=> {:tag 4 :grubs {"1" {:completed false, :text "2 apples"}} :recipes {}}))
(facts "Full sync"
(fact "Server sends full sync if client requests it"
(let [result (sync/handle-event
{:type :full-sync-request
:states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:tag 15 :grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}])})]
(:new-states result) => nil
(:new-shadow result) =>
(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 {}})
out-event => {:type :diff
:diff {:grubs {:deleted #{}
:updated {"2" {:completed false, :text "3 onions"}}}
:recipes {:deleted #{}, :updated nil}}
:hash (hasch/uuid {:grubs {"1" {:text "2 apples" :completed true}}
:recipes {}})}))
(fact "Server forces full sync if client is out of sync"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
event {:type :diff
:diff {:grubs {:updated {"0" {:completed true}} :deleted #{}}}
:hash (:hash {:grubs {"0" {:text "milk" :completed false}}
:recipes {}})
:states states
:shadow state/empty-state
:client? false}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => nil
out-event => {:type :full-sync
:full-state {:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}}))
(fact "Server sends full sync if client requests it"
(let [states (atom (hashed-states
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
{:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}))
event {:type :full-sync-request
:states states}
{:keys [new-states new-shadow out-event]} (sync/handle-event event)]
new-states => nil
out-event => {:type :full-sync
:full-state {:grubs {"1" {:text "2 apples" :completed false}
"2" {:text "3 onions" :completed false}}
:recipes {}}}))
:recipes {}}})))