Separate server and client syncing
- They vary in important ways anyway so it's more clear this way
This commit is contained in:
parent
455cf54bc9
commit
8b11c119f2
6 changed files with 117 additions and 105 deletions
|
@ -3,7 +3,7 @@
|
||||||
(:require [grub.websocket :as ws]
|
(:require [grub.websocket :as ws]
|
||||||
[grub.db :as db]
|
[grub.db :as db]
|
||||||
[grub.state :as state]
|
[grub.state :as state]
|
||||||
[grub.sync :as sync]
|
[grub.server-sync :as sync]
|
||||||
[ring.middleware.resource :as resource]
|
[ring.middleware.resource :as resource]
|
||||||
[ring.middleware.content-type :as content-type]
|
[ring.middleware.content-type :as content-type]
|
||||||
[ring.util.response :as resp]
|
[ring.util.response :as resp]
|
||||||
|
|
|
@ -1,17 +1,13 @@
|
||||||
(ns grub.sync
|
(ns grub.client-sync
|
||||||
(:require [grub.diff :as diff]
|
(:require [grub.diff :as diff]
|
||||||
[grub.state :as state]
|
[grub.state :as state]
|
||||||
#?(:clj [clojure.core.async :as a :refer [<! >! chan go]]
|
#?(:cljs [cljs.core.async :as a :refer [<! >! chan]]
|
||||||
:cljs [cljs.core.async :as a :refer [<! >! chan]]))
|
:clj [clojure.core.async :as a :refer [<! >! chan go]]))
|
||||||
#?(:cljs (:require-macros [grub.macros :refer [log logs]]
|
#?(:cljs (:require-macros [grub.macros :refer [log logs]]
|
||||||
[cljs.core.async.macros :refer [go]])))
|
[cljs.core.async.macros :refer [go]])))
|
||||||
|
|
||||||
(def full-sync-request {:type :full-sync-request})
|
(def full-sync-request {:type :full-sync-request})
|
||||||
|
|
||||||
(defn full-sync [state]
|
|
||||||
{:type :full-sync
|
|
||||||
:full-state state})
|
|
||||||
|
|
||||||
(def empty-state state/empty-state)
|
(def empty-state state/empty-state)
|
||||||
|
|
||||||
(defn update-states [states diff]
|
(defn update-states [states diff]
|
||||||
|
@ -28,76 +24,33 @@
|
||||||
|
|
||||||
(defmulti handle-event (fn [event] (:type event)))
|
(defmulti handle-event (fn [event] (:type event)))
|
||||||
|
|
||||||
(defn apply-diff [states diff shadow new-shadow-tag client?]
|
(defn apply-diff [states diff shadow new-shadow-tag]
|
||||||
(let [new-states (swap! states update-states diff)
|
(swap! states update-states diff)
|
||||||
new-state (state/get-latest new-states)
|
(let [new-shadow (assoc (diff/patch-state shadow diff) :tag new-shadow-tag)]
|
||||||
new-shadow (assoc (diff/patch-state shadow diff)
|
{:new-shadow new-shadow :out-event nil}))
|
||||||
:tag new-shadow-tag)]
|
|
||||||
{:new-shadow new-shadow
|
|
||||||
;; Workaround to send an "ACK" diff when there are no changes
|
|
||||||
: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 shadow-tag tag client?]}]
|
(defmethod handle-event :diff [{:keys [diff states shadow shadow-tag tag]}]
|
||||||
(let [history-shadow (state/get-tagged @states shadow-tag)]
|
(let [history-shadow (state/get-tagged @states shadow-tag)]
|
||||||
(if history-shadow
|
(if history-shadow
|
||||||
(apply-diff states diff history-shadow tag client?)
|
(apply-diff states diff history-shadow tag)
|
||||||
(if client?
|
{:out-event full-sync-request
|
||||||
{:out-event full-sync-request
|
:new-shadow shadow} )))
|
||||||
:new-shadow shadow}
|
|
||||||
(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-latest @states)]
|
|
||||||
{:new-shadow state
|
|
||||||
:out-event (full-sync state)}))
|
|
||||||
|
|
||||||
(defmethod handle-event :full-sync [{:keys [full-state states]}]
|
(defmethod handle-event :full-sync [{:keys [full-state states]}]
|
||||||
(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?]}]
|
(defmethod handle-event :new-state [{:keys [shadow states new-state]}]
|
||||||
(let [new-states (swap! states state/add new-state)
|
(let [new-states (swap! states state/add new-state)
|
||||||
latest-state (state/get-latest new-states)]
|
latest-state (state/get-latest new-states)]
|
||||||
{:out-event (when-not (state/state= shadow latest-state)
|
{:out-event (when-not (state/state= shadow latest-state)
|
||||||
(diff-msg shadow latest-state))
|
(diff-msg shadow latest-state))
|
||||||
:new-shadow (when (and (not client?)
|
:new-shadow nil}))
|
||||||
(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))
|
|
||||||
{})
|
{})
|
||||||
|
|
||||||
(defn make-server-agent
|
|
||||||
([>remote events new-states states]
|
|
||||||
(make-server-agent >remote events new-states states state/empty-state))
|
|
||||||
([>remote events new-states states initial-shadow]
|
|
||||||
(go (loop [shadow initial-shadow]
|
|
||||||
(let [[v c] (a/alts! [new-states events] :priority true)]
|
|
||||||
(cond (nil? v) nil ;; drop out of loop
|
|
||||||
(= c new-states)
|
|
||||||
(let [event {:type :new-state
|
|
||||||
:new-state v
|
|
||||||
:shadow 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)
|
|
||||||
(let [event (assoc v
|
|
||||||
:states states
|
|
||||||
:client? false
|
|
||||||
:shadow shadow)
|
|
||||||
{:keys [new-shadow out-event]} (handle-event event)]
|
|
||||||
(when out-event (a/put! >remote out-event))
|
|
||||||
(recur (if new-shadow new-shadow shadow)))))))))
|
|
||||||
|
|
||||||
(defn make-client-agent
|
(defn make-client-agent
|
||||||
([>remote events new-states states]
|
([>remote events new-states states]
|
||||||
(make-client-agent >remote events new-states states state/empty-state))
|
(make-client-agent >remote events new-states states state/empty-state))
|
||||||
|
@ -115,14 +68,12 @@
|
||||||
(let [event {:type :new-state
|
(let [event {:type :new-state
|
||||||
:new-state v
|
:new-state v
|
||||||
:shadow shadow
|
:shadow shadow
|
||||||
:states states
|
:states states}
|
||||||
:client? true}
|
|
||||||
{:keys [out-event]} (handle-event event)]
|
{:keys [out-event]} (handle-event event)]
|
||||||
(recur shadow out-event))
|
(recur shadow out-event))
|
||||||
(= c events)
|
(= c events)
|
||||||
(let [event (assoc v
|
(let [event (assoc v
|
||||||
:states states
|
:states states
|
||||||
:client? true
|
|
||||||
:shadow shadow)
|
:shadow shadow)
|
||||||
{:keys [new-shadow out-event]} (handle-event event)]
|
{:keys [new-shadow out-event]} (handle-event event)]
|
||||||
(recur (if new-shadow new-shadow shadow) out-event))))))))
|
(recur (if new-shadow new-shadow shadow) out-event))))))))
|
85
src/cljc/grub/server_sync.cljc
Normal file
85
src/cljc/grub/server_sync.cljc
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
(ns grub.server-sync
|
||||||
|
(:require [grub.diff :as diff]
|
||||||
|
[grub.state :as state]
|
||||||
|
#?(:cljs [cljs.core.async :as a :refer [<! >! chan]]
|
||||||
|
:clj [clojure.core.async :as a :refer [<! >! chan go]]))
|
||||||
|
#?(:cljs (:require-macros [grub.macros :refer [log logs]]
|
||||||
|
[cljs.core.async.macros :refer [go]])))
|
||||||
|
|
||||||
|
(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-latest states)
|
||||||
|
new-state (diff/patch-state state diff)]
|
||||||
|
(state/add states new-state)))
|
||||||
|
|
||||||
|
(defn diff-msg [shadow state]
|
||||||
|
(let [diff (diff/diff-states shadow state)]
|
||||||
|
{:type :diff
|
||||||
|
:diff diff
|
||||||
|
:tag (:tag state)
|
||||||
|
:shadow-tag (:tag shadow)}))
|
||||||
|
|
||||||
|
(defmulti handle-event (fn [event] (:type event)))
|
||||||
|
|
||||||
|
(defn apply-diff [states diff shadow new-shadow-tag]
|
||||||
|
(let [new-states (swap! states update-states diff)
|
||||||
|
new-state (state/get-latest new-states)
|
||||||
|
new-shadow (assoc (diff/patch-state shadow diff)
|
||||||
|
:tag new-shadow-tag)]
|
||||||
|
{:new-shadow new-shadow
|
||||||
|
;; Workaround to send an "ACK" diff when there are no changes
|
||||||
|
:out-event (when (state/state= new-state new-shadow)
|
||||||
|
(diff-msg new-shadow new-state))}))
|
||||||
|
|
||||||
|
(defmethod handle-event :diff [{:keys [diff states shadow-tag tag]}]
|
||||||
|
(let [history-shadow (state/get-tagged @states shadow-tag)]
|
||||||
|
(if history-shadow
|
||||||
|
(apply-diff states diff history-shadow tag)
|
||||||
|
(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-latest @states)]
|
||||||
|
{:new-shadow state
|
||||||
|
:out-event (full-sync state)}))
|
||||||
|
|
||||||
|
(defmethod handle-event :new-state [{:keys [shadow states new-state]}]
|
||||||
|
(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-not (state/state= shadow latest-state)
|
||||||
|
(assoc latest-state :tag (inc (:tag shadow))))}))
|
||||||
|
|
||||||
|
(defmethod handle-event :default [msg]
|
||||||
|
#?(:clj (println "Unhandled message:" msg))
|
||||||
|
{})
|
||||||
|
|
||||||
|
(defn make-server-agent
|
||||||
|
([>remote events new-states states]
|
||||||
|
(make-server-agent >remote events new-states states state/empty-state))
|
||||||
|
([>remote events new-states states initial-shadow]
|
||||||
|
(go (loop [shadow initial-shadow]
|
||||||
|
(let [[v c] (a/alts! [new-states events] :priority true)]
|
||||||
|
(cond (nil? v) nil ;; drop out of loop
|
||||||
|
(= c new-states)
|
||||||
|
(let [event {:type :new-state
|
||||||
|
:new-state v
|
||||||
|
:shadow shadow
|
||||||
|
:states states}
|
||||||
|
{: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)
|
||||||
|
(let [event (assoc v
|
||||||
|
:states states
|
||||||
|
:shadow shadow)
|
||||||
|
{:keys [new-shadow out-event]} (handle-event event)]
|
||||||
|
(when out-event (a/put! >remote out-event))
|
||||||
|
(recur (if new-shadow new-shadow shadow)))))))))
|
|
@ -1,6 +1,6 @@
|
||||||
(ns grub.core
|
(ns grub.core
|
||||||
(:require [grub.state :as state]
|
(:require [grub.state :as state]
|
||||||
[grub.sync :as sync]
|
[grub.client-sync :as sync]
|
||||||
[grub.websocket :as websocket]
|
[grub.websocket :as websocket]
|
||||||
[grub.view.app :as view]
|
[grub.view.app :as view]
|
||||||
[cljs.core.async :as a :refer [<! >! chan]])
|
[cljs.core.async :as a :refer [<! >! chan]])
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
(ns grub.test.integration.synchronization
|
(ns grub.test.integration.synchronization
|
||||||
(:require [grub.sync :as sync]
|
(:require [grub.client-sync :as client-sync]
|
||||||
[grub.state :as state]
|
[grub.server-sync :as server-sync]
|
||||||
[clojure.test :refer :all]
|
[clojure.test :refer :all]
|
||||||
[midje.sweet :refer :all]
|
[midje.sweet :refer :all]
|
||||||
[clojure.core.async :as a :refer [<!! >!! chan go]]))
|
[clojure.core.async :as a :refer [<!! >!! chan go]]))
|
||||||
|
|
||||||
(defn <!!? [c]
|
|
||||||
(let [[v p] (a/alts!! [c (a/timeout 100)])]
|
|
||||||
v))
|
|
||||||
|
|
||||||
(defn client-server [client-states server-states]
|
(defn client-server [client-states server-states]
|
||||||
(let [server-shadow (last @server-states)
|
(let [server-shadow (last @server-states)
|
||||||
client-shadow (last @client-states)
|
client-shadow (last @client-states)
|
||||||
|
@ -16,8 +12,8 @@
|
||||||
>client (chan)
|
>client (chan)
|
||||||
new-server-states (chan)
|
new-server-states (chan)
|
||||||
>server (chan)]
|
>server (chan)]
|
||||||
(sync/make-client-agent >server >client new-client-states client-states server-shadow)
|
(client-sync/make-client-agent >server >client new-client-states client-states server-shadow)
|
||||||
(sync/make-server-agent >client >server new-server-states server-states client-shadow)
|
(server-sync/make-server-agent >client >server new-server-states server-states client-shadow)
|
||||||
{:new-client-states new-client-states
|
{:new-client-states new-client-states
|
||||||
:new-server-states new-server-states}))
|
:new-server-states new-server-states}))
|
||||||
|
|
||||||
|
@ -92,4 +88,3 @@
|
||||||
(last-state @client) => {:grubs {"1" {:text "2 apples" :completed true}
|
(last-state @client) => {:grubs {"1" {:text "2 apples" :completed true}
|
||||||
"2" {:text "milk" :completed false}}
|
"2" {:text "milk" :completed false}}
|
||||||
:recipes {}}))
|
:recipes {}}))
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
(ns grub.test.unit.sync
|
(ns grub.test.unit.sync
|
||||||
(:require [grub.state :as state]
|
(:require [grub.state :as state]
|
||||||
[grub.sync :as sync]
|
[grub.client-sync :as client-sync]
|
||||||
|
[grub.server-sync :as server-sync]
|
||||||
[midje.sweet :refer :all]))
|
[midje.sweet :refer :all]))
|
||||||
|
|
||||||
(facts "Server"
|
(facts "Server"
|
||||||
(fact "Diff, no server changes - Apply diff, return empty diff"
|
(fact "Diff, no server changes - Apply diff, return empty diff"
|
||||||
(let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
|
(let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])
|
||||||
{:keys [out-event new-shadow]}
|
{:keys [out-event new-shadow]}
|
||||||
(sync/handle-event
|
(server-sync/handle-event
|
||||||
{:type :diff
|
{:type :diff
|
||||||
:tag 4
|
:tag 4
|
||||||
:shadow-tag 0
|
:shadow-tag 0
|
||||||
|
@ -35,7 +36,7 @@
|
||||||
"2" {:text "3 onions" :completed false}}
|
"2" {:text "3 onions" :completed false}}
|
||||||
:recipes {}}])
|
:recipes {}}])
|
||||||
{:keys [new-shadow out-event]}
|
{:keys [new-shadow out-event]}
|
||||||
(sync/handle-event
|
(server-sync/handle-event
|
||||||
{:type :diff
|
{:type :diff
|
||||||
:shadow-tag 0
|
:shadow-tag 0
|
||||||
:tag 4
|
:tag 4
|
||||||
|
@ -66,7 +67,7 @@
|
||||||
:states states
|
:states states
|
||||||
:shadow state/empty-state
|
:shadow state/empty-state
|
||||||
:client? false}
|
:client? false}
|
||||||
{:keys [new-shadow out-event]} (sync/handle-event event)]
|
{:keys [new-shadow out-event]} (server-sync/handle-event event)]
|
||||||
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}
|
||||||
|
@ -80,7 +81,7 @@
|
||||||
:shadow {:tag 3 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
:shadow {:tag 3 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||||
:client? false
|
:client? false
|
||||||
:new-state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}
|
:new-state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}
|
||||||
{:keys [new-shadow out-event]} (sync/handle-event event)]
|
{:keys [new-shadow out-event]} (server-sync/handle-event event)]
|
||||||
@states => [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
@states => [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||||
{:tag 15 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}]
|
{:tag 15 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}]
|
||||||
new-shadow => {:tag 4 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}
|
new-shadow => {:tag 4 :grubs {"1" {:text "2 apples" :completed true}} :recipes {}}
|
||||||
|
@ -91,7 +92,7 @@
|
||||||
:recipes {:+ nil :- #{}}}}))
|
:recipes {:+ nil :- #{}}}}))
|
||||||
|
|
||||||
(fact "Server sends full sync if client requests it"
|
(fact "Server sends full sync if client requests it"
|
||||||
(let [result (sync/handle-event
|
(let [result (server-sync/handle-event
|
||||||
{:type :full-sync-request
|
{:type :full-sync-request
|
||||||
:states (atom [{:tag 14 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
: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}
|
||||||
|
@ -122,7 +123,7 @@
|
||||||
: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-shadow out-event]} (sync/handle-event event)]
|
{:keys [new-shadow out-event]} (client-sync/handle-event event)]
|
||||||
@states =>
|
@states =>
|
||||||
(just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
|
(just {:tag 0 :grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
|
||||||
{:tag 1
|
{:tag 1
|
||||||
|
@ -133,7 +134,7 @@
|
||||||
|
|
||||||
(fact "Client state is unchanged on receiving empty diff"
|
(fact "Client state is unchanged on receiving empty diff"
|
||||||
(let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])]
|
(let [states (atom [{:tag 0 :grubs {"1" {:text "2 apples" :completed false}} :recipes {}}])]
|
||||||
(sync/handle-event
|
(client-sync/handle-event
|
||||||
{:type :diff
|
{:type :diff
|
||||||
:shadow-tag 0
|
:shadow-tag 0
|
||||||
:tag 4
|
:tag 4
|
||||||
|
@ -146,7 +147,7 @@
|
||||||
:recipes {}}]))
|
:recipes {}}]))
|
||||||
|
|
||||||
(fact "Client returns no response on empty diff"
|
(fact "Client returns no response on empty diff"
|
||||||
(-> (sync/handle-event
|
(-> (client-sync/handle-event
|
||||||
{:type :diff
|
{:type :diff
|
||||||
:shadow-tag 0
|
:shadow-tag 0
|
||||||
:tag 4
|
:tag 4
|
||||||
|
@ -158,7 +159,7 @@
|
||||||
=> nil)
|
=> nil)
|
||||||
|
|
||||||
(fact "Client updates server shadow on empty diff"
|
(fact "Client updates server shadow on empty diff"
|
||||||
(-> (sync/handle-event
|
(-> (client-sync/handle-event
|
||||||
{:type :diff
|
{:type :diff
|
||||||
:shadow-tag 0
|
:shadow-tag 0
|
||||||
:tag 4
|
:tag 4
|
||||||
|
@ -168,23 +169,3 @@
|
||||||
:client? true})
|
:client? true})
|
||||||
:new-shadow)
|
:new-shadow)
|
||||||
=> {:tag 4 :grubs {"1" {:completed false, :text "2 apples"}} :recipes {}}))
|
=> {: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-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 {}}})))
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue