(ns pocketbook.core "Pocketbook: a Clojure-native synced atom. Usage: (def conn (pocketbook/open \"my-app\")) (def todos (pocketbook/synced-atom conn \"todo\" {:server \"http://localhost:8090/sync\"})) (go ( {\"todo:1\" {:text \"Buy milk\"}} " (:require [pocketbook.idb :as idb] [pocketbook.sync :as sync] [clojure.string :as str] [cljs.core.async :as async :refer [go go-loop ! chan put! timeout alts!]])) ;; --------------------------------------------------------------------------- ;; Connection (IDB handle) ;; --------------------------------------------------------------------------- (defn open "Open a Pocketbook connection (IndexedDB database). Returns a channel yielding the connection map." [db-name] (let [ch (chan 1)] (go (let [db (! ch {:db db :db-name db-name}) (async/close! ch))) ch)) (defn shutdown! "Close a Pocketbook connection." [{:keys [db atoms]}] ;; Stop all sync loops (doseq [[_ sa] @(or atoms (atom {}))] (when-let [stop (:stop-fn sa)] (stop))) (idb/close-db! db)) ;; --------------------------------------------------------------------------- ;; Synced Atom — implements IAtom semantics ;; --------------------------------------------------------------------------- (deftype SyncedAtom [group ;; string prefix, e.g. "todo" conn ;; {:db idb, ...} cache ;; atom containing {id -> value} versions ;; atom containing {id -> version} pending ;; atom containing #{id} — unsynced ids server-opts ;; {:server url} or nil last-sync ;; atom containing epoch ms ready-ch ;; channel, closed when initial load complete stop-ch ;; channel to signal stop kick-ch ;; channel to trigger immediate push cleanup-fn ;; atom holding connectivity cleanup fn sync-interval ;; ms _meta] ;; metadata atom IAtom IDeref (-deref [_] @cache) IReset (-reset! [_ new-val] ;; Replace the entire cache (all docs in group) (let [old @cache] (reset! cache new-val) ;; Track which docs changed/added/removed (let [all-keys (into (set (keys old)) (keys new-val)) changed? (volatile! false)] (doseq [k all-keys] (when (not= (get old k) (get new-val k)) (vreset! changed? true) (swap! pending conj k) ;; Write to IDB (let [v (get new-val k)] (if (nil? v) ;; Doc was dissoc'd — mark deleted (idb/put-doc! (:db conn) {:id k :value nil :version (get @versions k 0) :updated (.now js/Date) :deleted true :synced false}) (idb/put-doc! (:db conn) {:id k :value v :version (get @versions k 0) :updated (.now js/Date) :deleted false :synced false}))))) ;; Kick the sync loop to push immediately (when @changed? (put! kick-ch :kick))) new-val)) ISwap (-swap! [o f] (-reset! o (f @cache))) (-swap! [o f a] (-reset! o (f @cache a))) (-swap! [o f a b] (-reset! o (f @cache a b))) (-swap! [o f a b xs] (-reset! o (apply f @cache a b xs))) IWatchable (-add-watch [_ key f] (add-watch cache key f)) (-remove-watch [_ key] (remove-watch cache key)) (-notify-watches [_ old new] ;; Delegated to the inner atom nil) IMeta (-meta [_] @_meta) IWithMeta (-with-meta [_ m] (reset! _meta m)) IPrintWithWriter (-pr-writer [_ writer opts] (-write writer (str "#")))) ;; --------------------------------------------------------------------------- ;; Internal helpers ;; --------------------------------------------------------------------------- (defn- prefix-str [group] (str group ":")) (defn- now-ms [] (.now js/Date)) (defn- doc-in-group? [group id] (str/starts-with? id (prefix-str group))) ;; --------------------------------------------------------------------------- ;; IDB ↔ Atom sync ;; --------------------------------------------------------------------------- (defn- load-from-idb! "Load all docs for the group from IndexedDB into the atom. Returns a channel that closes when done." [sa] (let [ch (chan 1)] (go (let [prefix (prefix-str (.-group sa)) docs ( local version (when (> (:version doc) (get @(.-versions sa) id 0)) (if (:deleted doc) (do (swap! (.-cache sa) dissoc id) (swap! (.-versions sa) assoc id (:version doc)) (idb/put-doc! (:db (.-conn sa)) {:id id :value nil :version (:version doc) :updated (:updated doc) :deleted true :synced true})) (do (swap! (.-cache sa) assoc id (:value doc)) (swap! (.-versions sa) assoc id (:version doc)) (idb/put-doc! (:db (.-conn sa)) {:id id :value (:value doc) :version (:version doc) :updated (:updated doc) :deleted false :synced true})))))) ;; Update last-sync (reset! (.-last_sync sa) max-ts) (idb/set-meta! (:db (.-conn sa)) (str "last-sync:" (.-group sa)) max-ts)) true))))) (defn- do-push! "Push all unsynced local docs to the server." [sa] (go (when-let [opts (.-server_opts sa)] (let [pending-ids @(.-pending sa)] (when (seq pending-ids) (let [docs (mapv (fn [id] (let [v (get @(.-cache sa) id)] (if (nil? v) {:id id :deleted true :base-version (get @(.-versions sa) id 0)} {:id id :value v :base-version (get @(.-versions sa) id 0)}))) pending-ids) result (