├── .gitignore ├── test └── om_sync │ └── core_test.clj ├── project.clj ├── src └── om_sync │ ├── util.cljs │ └── core.cljs ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | app.js 2 | out 3 | /target 4 | /classes 5 | /checkouts 6 | pom.xml 7 | pom.xml.asc 8 | *.jar 9 | *.class 10 | /.lein-* 11 | /.nrepl-port 12 | -------------------------------------------------------------------------------- /test/om_sync/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns om-sync.core-test 2 | (:require [clojure.test :refer :all] 3 | [om-sync.core :refer :all])) 4 | 5 | (deftest a-test 6 | (testing "FIXME, I fail." 7 | (is (= 0 1)))) 8 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject om-sync "0.1.1" 2 | :description "A sync component for Om" 3 | :url "http://github.com/swannodette/om-sync" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | 7 | :dependencies [[org.clojure/clojure "1.5.1"] 8 | [org.clojure/clojurescript "0.0-2156" :scope "provided"] 9 | [org.clojure/core.async "0.1.278.0-76b25b-alpha" :scope "provided"] 10 | [om "0.5.0" :scope "provided"]] 11 | 12 | :plugins [[lein-cljsbuild "1.0.2"]] 13 | 14 | :cljsbuild { 15 | :builds [{:id "test" 16 | :source-paths ["src"] 17 | :compiler { 18 | :output-to "app.js" 19 | :output-dir "out" 20 | :optimizations :none 21 | :source-map true}}]}) 22 | -------------------------------------------------------------------------------- /src/om_sync/util.cljs: -------------------------------------------------------------------------------- 1 | (ns om-sync.util 2 | (:require [cljs.reader :as reader] 3 | [goog.events :as events]) 4 | (:import [goog.net XhrIo] 5 | goog.net.EventType 6 | [goog.events EventType])) 7 | 8 | (defn popn [n v] 9 | (loop [n n res v] 10 | (if (pos? n) 11 | (recur (dec n) (pop res)) 12 | res))) 13 | 14 | (defn sub [p0 p1] 15 | (vec (drop (- (count p0) (count p1)) p0))) 16 | 17 | (defn tx-tag [{:keys [tag] :as tx-data}] 18 | (if (keyword? tag) 19 | tag 20 | (first tag))) 21 | 22 | (defn subpath? [a b] 23 | (= a (popn (- (count b) (count a)) b))) 24 | 25 | (defn error? [res] 26 | (contains? res :error)) 27 | 28 | (def ^:private meths 29 | {:get "GET" 30 | :put "PUT" 31 | :post "POST" 32 | :delete "DELETE"}) 33 | 34 | (defn edn-xhr [{:keys [method url data on-complete on-error]}] 35 | (let [xhr (XhrIo.)] 36 | (events/listen xhr goog.net.EventType.SUCCESS 37 | (fn [e] 38 | (on-complete (reader/read-string (.getResponseText xhr))))) 39 | (events/listen xhr goog.net.EventType.ERROR 40 | (fn [e] 41 | (on-error {:error (.getResponseText xhr)}))) 42 | (. xhr 43 | (send url (meths method) (when data (pr-str data)) 44 | #js {"Content-Type" "application/edn" "Accept" "application/edn"})))) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # om-sync 2 | 3 | A reusable synchronization component for 4 | [Om](http://github.com/swannodette/om). 5 | 6 | ## Example 7 | 8 | `om-sync` leverages the new `:tx-listen` option allowed by 9 | `om.core/root`. For example you can imagine setting up your 10 | application like so: 11 | 12 | ```clj 13 | (let [tx-chan (chan) 14 | tx-pub-chan (async/pub tx-chan (fn [_] :txs))] 15 | (om-sync.util/edn-xhr 16 | {:method :get 17 | :url "/init" 18 | :on-complete 19 | (fn [res] 20 | (reset! app-state res) 21 | (om/root app-view app-state 22 | {:target (gdom/getElement "app") 23 | :shared {:tx-chan tx-pub-chan} 24 | :tx-listen 25 | (fn [tx-data root-cursor] 26 | (put! tx-chan [tx-data root-cursor]))}))})) 27 | ``` 28 | 29 | We publish a transaction queue channel `:tx-chan` as a global service 30 | via `:shared` so that `om-sync` instances can listen in. 31 | 32 | We can now take any application data and wrap it in an `om-sync` 33 | instance. Whenever application data changes `om-sync` will synchronize 34 | those changes via [EDN](http://github.com/edn-format/edn) requests to 35 | a server. 36 | 37 | Notice in the following that `om-sync` can take an `:on-error` 38 | handler. This error handler will be given `tx-data` which contains 39 | enough information to roll back the entire application state if 40 | something goes wrong. 41 | 42 | ```clj 43 | (defn app-view [app owner] 44 | (reify 45 | om/IWillUpdate 46 | (will-update [_ next-props next-state] 47 | (when (:err-msg next-state) 48 | (js/setTimeout #(om/set-state! owner :err-msg nil) 5000))) 49 | om/IRenderState 50 | (render-state [_ {:keys [err-msg]}] 51 | (dom/div nil 52 | (om/build om-sync (:items app) 53 | {:opts {:view items-view 54 | :filter (comp #{:create :update :delete} tx-tag) 55 | :id-key :some/id 56 | :on-success (fn [res tx-data] (println res)) 57 | :on-error 58 | (fn [err tx-data] 59 | (reset! app-state (:old-state tx-data)) 60 | (om/set-state! owner :err-msg 61 | "Ooops! Sorry something went wrong try again later."))}}) 62 | (when err-msg 63 | (dom/div nil err-msg)))))) 64 | ``` 65 | 66 | `om.core/transact!` and `om.core/update!` now support tagging 67 | transactions with a keyword or a vector that starts with a 68 | keyword. `om-sync` listens in on transactions labeled `:create`, 69 | `:update`, and `:delete`. 70 | 71 | ```clj 72 | (defn foo [some-data owner] 73 | (om.core/transact! some-data :foo (fn [_] :bar) :update)) 74 | ``` 75 | 76 | If you are given some component that does not tag its transactions or 77 | the tags do not correspond to `:create`, `:update`, and `:delete` you 78 | can provide a `:tag-fn` via `:opts` to `om-sync` so that you can 79 | classify the transaction yourself. 80 | 81 | ## Contributions 82 | 83 | Pull requests welcome. 84 | 85 | ## License 86 | 87 | Copyright © 2014 David Nolen 88 | 89 | Distributed under the Eclipse Public License either version 1.0 or (at 90 | your option) any later version. 91 | -------------------------------------------------------------------------------- /src/om_sync/core.cljs: -------------------------------------------------------------------------------- 1 | (ns om-sync.core 2 | (:require-macros [cljs.core.async.macros :refer [go]]) 3 | (:require [cljs.core.async :as async :refer [put! chan alts!]] 4 | [om.core :as om :include-macros true] 5 | [om.dom :as dom :include-macros true] 6 | [om-sync.util :refer [edn-xhr popn sub tx-tag subpath? error?]])) 7 | 8 | (def ^:private type->method 9 | {:create :post 10 | :update :put 11 | :delete :delete}) 12 | 13 | (defn ^:private sync-server [url tag edn] 14 | (let [res-chan (chan)] 15 | (edn-xhr 16 | {:method (type->method tag) 17 | :url url 18 | :data edn 19 | :on-error (fn [err] (put! res-chan err)) 20 | :on-complete (fn [res] (put! res-chan res))}) 21 | res-chan)) 22 | 23 | (defn ^:private tag-and-edn [coll-path path tag-fn id-key tx-data] 24 | (let [tag (if-not (nil? tag-fn) 25 | (tag-fn tx-data) 26 | (tx-tag tx-data)) 27 | edn (condp = tag 28 | :create (:new-value tx-data) 29 | :update (let [ppath (popn (- (count path) (inc (count coll-path))) path) 30 | m (select-keys (get-in (:new-state tx-data) ppath) [id-key]) 31 | rel (sub path coll-path)] 32 | (assoc-in m (rest rel) (:new-value tx-data))) 33 | :delete (-> tx-data :old-value id-key) 34 | nil)] 35 | [tag edn])) 36 | 37 | (defn om-sync 38 | "ALPHA: Creates a reusable sync componet. Data must be a map containing 39 | :url and :coll keys. :url must identify a server endpoint that can 40 | takes EDN data via POST for create, PUT for update, and DELETE for 41 | delete. :coll must be a cursor into the application state. Note the 42 | first argument could of course just be a cursor itself. 43 | 44 | In order to function you must provide a subscribeable core.async 45 | channel that will stream all :tx-listen events. This channel must be 46 | called :tx-chan and provided via the :share option to om.core/root. It 47 | must be a channel constructed with cljs.core.async/pub with the topic 48 | :txs. 49 | 50 | Once built om-sync will act on any transactions to the :coll value 51 | regardless of depth. In order to identify which transactions to act 52 | on these transactions must be labeled as :create, :update, or 53 | :delete. 54 | 55 | om-sync takes a variety of options via the :opts passed to 56 | om.core/build: 57 | 58 | :view - a required Om component function to render the collection. 59 | 60 | :id-key - which property represents the server id for a item in the 61 | collection. 62 | 63 | :filter - a function which filters which tagged transaction to actually sync. 64 | 65 | :tag-fn - not all components you might want to interact with may 66 | have properly tagged their transactions. This function will 67 | receive the transaction data and return the determined tag. 68 | 69 | :on-success - a callback function that will receive the server 70 | response and the transaction data on success. 71 | 72 | :on-error - a callback function that will receive the server error 73 | and the transaction data on failure. The transaction data can 74 | easily be leveraged to rollback the application state. 75 | 76 | :sync-chan - if given this option, om-sync will not invoke 77 | sync-server instead it will put a map containing the :listen-path, 78 | :url, :tag, :edn, :on-success, :on-error, and :tx-data on the 79 | provided channel. This higher level operations such as server 80 | request batching and multiple om-sync component coordination." 81 | ([data owner] (om-sync data owner nil)) 82 | ([{:keys [url coll] :as data} owner opts] 83 | (assert (not (nil? url)) "om-sync component not given url") 84 | (reify 85 | om/IInitState 86 | (init-state [_] 87 | {:kill-chan (chan)}) 88 | om/IWillMount 89 | (will-mount [_] 90 | (let [{:keys [id-key filter tag-fn sync-chan]} opts 91 | {:keys [on-success on-error]} opts 92 | kill-chan (om/get-state owner :kill-chan) 93 | tx-chan (om/get-shared owner :tx-chan) 94 | txs (chan) 95 | coll-path (om/path coll)] 96 | (assert (not (nil? tx-chan)) 97 | "om-sync requires shared :tx-chan pub channel with :txs topic") 98 | (async/sub tx-chan :txs txs) 99 | (om/set-state! owner :txs txs) 100 | (go (loop [] 101 | (let [[v c] (alts! [txs kill-chan])] 102 | (if (= c kill-chan) 103 | :done 104 | (let [[{:keys [path new-value new-state] :as tx-data} _] v] 105 | (when (and (subpath? coll-path path) 106 | (or (nil? filter) (filter tx-data))) 107 | (let [[tag edn] (tag-and-edn coll-path path tag-fn id-key tx-data) 108 | tx-data (assoc tx-data ::tag tag)] 109 | (if-not (nil? sync-chan) 110 | (>! sync-chan 111 | {:url url :tag tag :edn edn 112 | :listen-path coll-path 113 | :on-success on-success 114 | :on-error on-error 115 | :tx-data tx-data}) 116 | (let [res (