diff --git a/deps.edn b/deps.edn index 7fcb3c8b..d0693a7b 100644 --- a/deps.edn +++ b/deps.edn @@ -2,9 +2,10 @@ :deps {thheller/shadow-cljs {:mvn/version "2.11.25"} devcards/devcards {:mvn/version "0.2.7"} datascript/datascript {:mvn/version "1.0.7"} - reagent/reagent {:mvn/version "1.0.0-alpha2"} + reagent/reagent {:mvn/version "1.0.0"} inflections/inflections {:mvn/version "0.13.2"} binaryage/devtools {:mvn/version "1.0.2"} - homebaseio/datalog-console {:git/url "https://github.com/homebaseio/datalog-console" :sha "97d5e5eb8994124ec8dc0029b33f2e88257b39b2"} + homebaseio/datalog-console {:git/url "https://github.com/homebaseio/datalog-console" :sha "fc4cf9ba968e995a67aae1816670adb78a81451d"} ;; homebaseio/datalog-console {:local/root "../datalog-console"} + nano-id {:mvn/version "1.0.0"} camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"}}} diff --git a/src/dev/example/core.cljs b/src/dev/example/core.cljs index 11e834d9..5ea2e656 100644 --- a/src/dev/example/core.cljs +++ b/src/dev/example/core.cljs @@ -6,10 +6,12 @@ [cljsjs.react.dom] [reagent.core] [devcards.core :as dc] - [dev.example.array] - [dev.example.counter] - [dev.example.todo] - [dev.example.todo-firebase])) + [dev.example.react.array] + [dev.example.react.counter] + [dev.example.react.todo] + [dev.example.react.todo-firebase] + [dev.example.reagent.counter] + [dev.example.reagent.todo])) (js/goog.exportSymbol "marked" marked) (js/goog.exportSymbol "DevcardsMarked" marked) diff --git a/src/dev/example/js/todo.jsx b/src/dev/example/js/todo.jsx index 6c89c9eb..09585d6e 100644 --- a/src/dev/example/js/todo.jsx +++ b/src/dev/example/js/todo.jsx @@ -163,7 +163,7 @@ const Todo = React.memo(({ id }) => {  ·  - {todo.get('createdAt').toLocaleString()} + {todo.get('createdAt')?.toLocaleString()} ) }) @@ -237,7 +237,8 @@ const TodoFilters = () => { type="checkbox" checked={filters.get('showCompleted')} onChange={(e) => - transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked } }])} + transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked } }]) + } />  ·  diff --git a/src/dev/example/js_compiled/todo.js b/src/dev/example/js_compiled/todo.js index 9f615b82..b68adaf3 100644 --- a/src/dev/example/js_compiled/todo.js +++ b/src/dev/example/js_compiled/todo.js @@ -171,7 +171,7 @@ const Todo = /*#__PURE__*/_react.default.memo(({ style: { color: 'grey' } - }, todo.get('createdAt').toLocaleString())); + }, todo.get('createdAt')?.toLocaleString())); }); const TodoCheck = ({ diff --git a/src/dev/example/array.cljs b/src/dev/example/react/array.cljs similarity index 89% rename from src/dev/example/array.cljs rename to src/dev/example/react/array.cljs index 13de02e3..32ec1947 100644 --- a/src/dev/example/array.cljs +++ b/src/dev/example/react/array.cljs @@ -1,8 +1,8 @@ -(ns dev.example.array +(ns dev.example.react.array (:require [devcards.core :as dc] [homebase.react] - ["./js_compiled/array" :as react-example]) + ["../js_compiled/array" :as react-example]) (:require-macros [devcards.core :refer [defcard-rg defcard-doc]] [dev.macros :refer [inline-resource]])) diff --git a/src/dev/example/counter.cljs b/src/dev/example/react/counter.cljs similarity index 89% rename from src/dev/example/counter.cljs rename to src/dev/example/react/counter.cljs index c4617d9c..e9c9a610 100644 --- a/src/dev/example/counter.cljs +++ b/src/dev/example/react/counter.cljs @@ -1,8 +1,8 @@ -(ns dev.example.counter +(ns dev.example.react.counter (:require [devcards.core :as dc] [homebase.react] - ["./js_compiled/counter" :as react-example]) + ["../js_compiled/counter" :as react-example]) (:require-macros [devcards.core :refer [defcard-rg defcard-doc]] [dev.macros :refer [inline-resource]])) diff --git a/src/dev/example/todo.cljs b/src/dev/example/react/todo.cljs similarity index 91% rename from src/dev/example/todo.cljs rename to src/dev/example/react/todo.cljs index 81f6ace6..93c8ffa4 100644 --- a/src/dev/example/todo.cljs +++ b/src/dev/example/react/todo.cljs @@ -1,8 +1,8 @@ -(ns dev.example.todo +(ns dev.example.react.todo (:require [devcards.core :as dc] [homebase.react] - ["./js_compiled/todo" :as react-example]) + ["../js_compiled/todo" :as react-example]) (:require-macros [devcards.core :refer [defcard-rg defcard-doc]] [dev.macros :refer [inline-resource]])) diff --git a/src/dev/example/todo_firebase.cljs b/src/dev/example/react/todo_firebase.cljs similarity index 88% rename from src/dev/example/todo_firebase.cljs rename to src/dev/example/react/todo_firebase.cljs index 6ad72fac..e4ce5723 100644 --- a/src/dev/example/todo_firebase.cljs +++ b/src/dev/example/react/todo_firebase.cljs @@ -1,8 +1,8 @@ -(ns dev.example.todo-firebase +(ns dev.example.react.todo-firebase (:require [devcards.core :as dc] [homebase.react] - ["./js_compiled/todo-firebase" :as react-example]) + ["../js_compiled/todo-firebase" :as react-example]) (:require-macros [devcards.core :refer [defcard-rg defcard-doc]] [dev.macros :refer [inline-resource]])) diff --git a/src/dev/example/reagent/counter.cljs b/src/dev/example/reagent/counter.cljs new file mode 100644 index 00000000..216925de --- /dev/null +++ b/src/dev/example/reagent/counter.cljs @@ -0,0 +1,27 @@ +(ns dev.example.reagent.counter + (:require + [devcards.core :as dc] + [datascript.core :as d] + [homebase.reagent :as hbr]) + (:require-macros + [devcards.core :refer [defcard-rg defcard-doc]] + [dev.macros :refer [inline-resource]])) + +(def db-conn (d/create-conn {})) +(hbr/connect! db-conn) +(d/transact! db-conn [[:db/add 1 :count 0]]) + +(defn counter [] + (let [[e] (hbr/entity db-conn 1)] + (fn [] + [:div + "Count: " (:count @e) + [:div + [:button {:on-click #(d/transact! db-conn [[:db/add 1 :count (inc (:count @e))]])} + "Increment"]]]))) + +(defcard-rg counter-example + counter) + +(defcard-doc + (str "```clojure\n" (inline-resource "src/dev/example/reagent/counter.cljs") "\n```")) \ No newline at end of file diff --git a/src/dev/example/reagent/todo.cljs b/src/dev/example/reagent/todo.cljs new file mode 100644 index 00000000..4cffe860 --- /dev/null +++ b/src/dev/example/reagent/todo.cljs @@ -0,0 +1,186 @@ +(ns dev.example.reagent.todo + (:require + [devcards.core :as dc] + [datascript.core :as d] + [reagent.core :as r] + [homebase.reagent :as hbr]) + (:require-macros + [devcards.core :refer [defcard-rg defcard-doc]] + [dev.macros :refer [inline-resource]])) + +(def db-conn (d/create-conn {:todo/project {:db/type :db.type/ref + :db/cardinality :db.cardinality/one} + :todo/owner {:db/type :db.type/ref + :db/cardinality :db.cardinality/one}})) +(hbr/connect! db-conn) +(d/transact! db-conn [{:todo/name "Go home" + :todo/created-at (js/Date.now) + :todo/owner -2 + :todo/project -3} + {:todo/name "Fix ship" + :todo/completed? true + :todo/created-at (js/Date.now) + :todo/owner -1 + :todo/project -4} + {:db/id -1 + :user/name "Stella"} + {:db/id -2 + :user/name "Arpegius"} + {:db/id -3 + :project/name "Do it"} + {:db/id -4 + :project/name "Make it"}]) + +#_(dotimes [n 1000] + (d/transact! db-conn [{:todo/name (str n) + :todo/created-at (js/Date.now)}])) + +(defn select [{:keys [label attr value on-change]}] + (let [[options] (hbr/q '[:find ?e ?v + :in $ ?attr + :where [?e ?attr ?v]] + db-conn attr)] + (fn [{:keys [label attr value on-change]}] + [:label label " " + [:select + {:name (str attr) + :value (or value "") + :on-change (fn [e] (when on-change (on-change (js/Number (goog.object/getValueByKeys e #js ["target" "value"])))))} + [:option {:value ""} ""] + (for [[id value] @options] + ^{:key id} [:option + {:value id} + value])]]))) + +(defn test-rev-ref [id] + (let [[todo] (hbr/entity db-conn id)] + (fn [] + [:div + [:button + {:on-click #(d/transact! db-conn [[:db/add (:db/id (:todo/owner @todo)) :user/name (str (rand-int 99))]])} + "change rev ref name"] + (:user/name (:todo/owner (first (:todo/_owner (:todo/owner @todo)))))]))) + +(defn test-ref [id] + (let [[todo] (hbr/entity db-conn id)] + (fn [] + [:div + [:button + {:on-click #(d/transact! db-conn [[:db/add (:db/id (:todo/owner @todo)) :user/name (str (rand-int 99))]])} + "change ref name"] + (:user/name (:todo/owner @todo))]))) + +(defn todo [id] + (let [[todo] (hbr/entity db-conn id)] + (fn [] + [:div {:style {:padding-bottom 20}} + [test-ref id] + [test-rev-ref id] + [:div + [:input + {:type "checkbox" + :style {:width "18px" :height "18px" :margin-left "0"} + :checked (true? (:todo/completed? @todo)) + :on-change #(d/transact! db-conn [[:db/add (:db/id @todo) :todo/completed? (goog.object/getValueByKeys % #js ["target" "checked"])]])}] + [:input + {:type "text" + :style {:text-decoration (when (:todo/completed? @todo) "line-through") :border "none" :width "auto" :font-weight "bold" :font-size "20px"} + :value (:todo/name @todo) + :on-change #(d/transact! db-conn [[:db/add (:db/id @todo) :todo/name (goog.object/getValueByKeys % #js ["target" "value"])]])}]] + [:div + [select + {:label "Owner:" + :attr :user/name + :value (get-in @todo [:todo/owner :db/id]) + :on-change (fn [owner-id] (d/transact! db-conn [[(if (= 0 owner-id) :db/retract :db/add) (:db/id @todo) :todo/owner (when (not= 0 owner-id) owner-id)]]))}] + " · " + [select + {:label "Project:" + :attr :project/name + :value (get-in @todo [:todo/project :db/id]) + :on-change (fn [project-id] (d/transact! db-conn [[(if (= 0 project-id) :db/retract :db/add) (:db/id @todo) :todo/project (when (not= 0 project-id) project-id)]]))}] + " · " + [:button + {:on-click #(d/transact! db-conn [[:db/retractEntity (:db/id @todo)]])} + "Delete"]] + [:div + [:small {:style {:color "grey"}} + (.toLocaleString (js/Date. (:todo/created-at @todo)))]]]))) + +(defn todo-filters [filters] + [:div {:style {:padding "20px 0"}} + [:strong "Filters · "] + [:label + "Show completed " + [:input + {:type "checkbox" + :checked (:f-all? @filters) + :on-change #(swap! filters assoc :f-all? (goog.object/getValueByKeys % #js ["target" "checked"]))}]] + " · " + [select + {:label "Owner" + :attr :user/name + :value (:f-owner @filters) + :on-change (fn [owner-id] + (swap! filters assoc :f-owner + (when (not= 0 owner-id) owner-id)))}] + " · " + [select + {:label "Project" + :attr :project/name + :value (:f-project @filters) + :on-change (fn [project-id] + (swap! filters assoc :f-project + (when (not= 0 project-id) project-id)))}] + ]) + +(defn todos [filters] + (let [[todos] (hbr/q '[:find [(pull ?todo [:db/id :todo/created-at :todo/project :todo/owner :todo/completed?]) ...] + :where + [?todo :todo/name]] + db-conn)] + (fn [filters] + (let [{:keys [f-project f-owner f-all?]} @filters] + [:div + [todo-filters filters] + [:div + (doall + (for [{:keys [db/id]} + (->> @todos + (remove (fn [{:keys [todo/project todo/owner todo/completed?]}] + (or (and f-project (not= (:db/id project) f-project)) + (and f-owner (not= (:db/id owner) f-owner)) + (and (not f-all?) completed?)))) + (sort-by :todo/created-at) + (reverse))] + ^{:key id} [todo id]))]])))) + +(def default-filters {:f-project nil :f-owner nil :f-all? true}) + +(defn new-todo [filters] + (let [name (r/atom "")] + (fn [filters] + [:form {:on-submit (fn [e] + (.preventDefault e) + (d/transact! db-conn [{:todo/name @name + :todo/created-at (js/Date.now)}]) + (reset! name "") + (reset! filters default-filters))} + [:input {:type "text" + :on-change #(reset! name (goog.object/getValueByKeys % #js ["target" "value"])) + :value @name + :placeholder "Write a todo..."}] + [:button {:type "submit"} "Create todo"]]))) + +(defn todo-app [] + (let [filters (r/atom default-filters)] + (fn [] + [:div + [new-todo filters] + [todos filters]]))) + +(defcard-rg todo-example + todo-app) + +(defcard-doc + (str "```clojure\n" (inline-resource "src/dev/example/reagent/todo.cljs") "\n```")) \ No newline at end of file diff --git a/src/homebase/cache.cljs b/src/homebase/cache.cljs new file mode 100644 index 00000000..560224e8 --- /dev/null +++ b/src/homebase/cache.cljs @@ -0,0 +1,84 @@ +(ns homebase.cache + (:require + [datascript.core :as datascript] + [datascript.db])) + +(defn create-conn [] + (atom + {:ea {} + :q {}})) + +(defn assoc-ea + [cache eid-attr-tuple reactive-lookup-uid change-handler] + (assoc-in cache [:ea eid-attr-tuple reactive-lookup-uid] change-handler)) + +(defn dissoc-ea + [cache eid-attr-tuple reactive-lookup-uid] + (let [cache (update-in cache [:ea eid-attr-tuple] dissoc reactive-lookup-uid)] + (if (empty? (get-in cache [:ea eid-attr-tuple])) + (update cache :ea dissoc eid-attr-tuple) + cache))) + +(defn assoc-q + [cache query reactive-lookup-uid change-handler] + (assoc-in cache [:q query reactive-lookup-uid] change-handler)) + +(defn dissoc-q + [cache query reactive-lookup-uid] + (let [cache (update-in cache [:q query] dissoc reactive-lookup-uid)] + (if (empty? (get-in cache [:q query])) + (update cache :q dissoc query) + cache))) + +(defn create-listener + "Returns a listener function that invokes all subscribed change-handlers in the cache when a datom is transacted." + [cache-conn] + (fn [{:keys [tx-data]}] + (let [cache @cache-conn + ;; The EA change-handler only needs to be triggered once for each reactive-lookup-uid. + triggered-ea-handlers (atom #{})] + ;; EA handlers + (doseq [[e a :as datom] tx-data] + (let [subscriptions (get-in cache [:ea [e a]])] + (doseq [[reactive-lookup-uid change-handler] subscriptions] + (when (not (get @triggered-ea-handlers reactive-lookup-uid)) + (swap! triggered-ea-handlers conj reactive-lookup-uid) + (change-handler {:datom datom + :reactive-lookup-uid reactive-lookup-uid}))))) + ;; Query handlers + ;; TODO: dispatch on change-handlers more judiciously instead of on every transaction. + ;; See work on incremental view manintinence e.g. https://github.com/sixthnormal/clj-3df + (let [subscriptions (mapcat seq (vals (:q cache)))] + (doseq [[reactive-lookup-uid change-handler] subscriptions] + (change-handler {:reactive-lookup-uid reactive-lookup-uid})))))) + +(defn db-conn-type [db-conn] + (if (instance? cljs.core/Atom db-conn) + (type @db-conn) + (type db-conn))) + +(defmulti connect! + "Connect the cache to a database connection and listen to changes in the transaction log." + (fn [cache-conn db-conn] (db-conn-type db-conn))) +(defmethod connect! datascript.db/DB [cache-conn db-conn] + (swap! db-conn with-meta (merge (meta @db-conn) {::conn cache-conn})) + (datascript/listen! db-conn ::connection (create-listener cache-conn))) + +(defmulti disconnect! + "Disconnect the transaction log listener." + (fn [db-conn] (db-conn-type db-conn))) +(defmethod disconnect! datascript.db/DB [db-conn] + (swap! db-conn with-meta (dissoc (meta @db-conn) ::conn)) + (datascript/unlisten! db-conn ::connection)) + +(comment + (do + (def cache-conn (create-conn)) + (def db-conn (datascript/create-conn {})) + (connect! cache-conn db-conn) + (swap! cache-conn assoc-ea [1 :a] "abc123" #(print "yolo" %))) + (datascript/transact! db-conn [{:a "a" :b "b" :c "c"}]) + (datascript/transact! db-conn [[:db/retract 1 :a]]) + (datascript/transact! db-conn [[:db/retractEntity 1]]) + (swap! cache-conn dissoc-ea [1 :a] "abc123") + (disconnect! db-conn)) \ No newline at end of file diff --git a/src/homebase/js.cljs b/src/homebase/js.cljs index e83f6fb8..5a193d7c 100644 --- a/src/homebase/js.cljs +++ b/src/homebase/js.cljs @@ -136,8 +136,9 @@ {} (js->clj lookup)))) (defmulti js->entity-lookup type) -(defmethod js->entity-lookup js/Number [lookup] lookup) (defmethod js->entity-lookup js/Object [lookup] (first (js->object-lookup lookup))) +(defmethod js->entity-lookup js/Number [lookup] lookup) +(defmethod js->entity-lookup :default [lookup] lookup) (comment (js->tx nil (clj->js [{:project {:array [[1] [2 {:k "v"}]]}}])) @@ -228,14 +229,21 @@ (reduced (namespace k)))) nil (keys entity))) -(defn js-get [^de/Entity entity name] +(defn js-guess-attr + "Takes an entity and a js name string and trys to guess the + ns and cljs name of the corresponding attribute in the given entity. + + Assumes that the entity was created by homebase.js and conforms to its conventions. + + Returns a keyword." + [^de/Entity entity name] (case name - "id" (:db/id entity) - "ident" (:db/ident entity) - "identity" (:db/ident entity) + "id" :db/id + "ident" :db/ident + "identity" :db/ident (let [maybe-ns (guess-entity-ns entity) k (when maybe-ns (js->key maybe-ns name))] - (when k (get entity k))))) + k))) (declare Entity @@ -284,9 +292,10 @@ (defn lookup-entity "Takes a homebase.js/Entity and a seq of attributes. Looks up the attribute path on the entity. Returns a scalar or homebase.js/Entity or js/Array of scalars or Entities." - ([entity attrs] (lookup-entity entity attrs false)) - ([entity attrs nil-attrs-if-not-in-db?] (lookup-entity entity attrs nil-attrs-if-not-in-db? nil)) - ([entity attrs nil-attrs-if-not-in-db? get-cb] + ([entity attrs] (lookup-entity entity attrs false nil nil)) + ([entity attrs nil-attrs-if-not-in-db?] (lookup-entity entity attrs nil-attrs-if-not-in-db? nil nil)) + ([entity attrs nil-attrs-if-not-in-db? get-cb] (lookup-entity entity attrs nil-attrs-if-not-in-db? get-cb nil)) + ([entity attrs nil-attrs-if-not-in-db? get-cb after-lookup] (humanize-error #(humanize-get-error % entity) (fn [] @@ -295,8 +304,13 @@ (if-not acc nil (let [attr (keywordize attr) - getter-fn (if (keyword? attr) get js-get) - getter-fn (comp (partial entity->js {:Entity/get-cb get-cb}) + getter-fn (fn [entity attr] + (let [attr (if (keyword? attr) attr (js-guess-attr entity attr)) + result (when attr (get entity attr))] + (when (and after-lookup attr) (after-lookup {:entity entity :attr attr :result result })) + result)) + getter-fn (comp (partial entity->js {:Entity/get-cb get-cb + ::after-lookup after-lookup}) getter-fn) result (cond (array? acc) (if (number? attr) @@ -333,17 +347,11 @@ (-contains-key? [this k] (not (nil? (lookup-entity this [k] true)))) Object (get [this & attrs] - (let [get-cb (:Entity/get-cb (meta this)) - v (lookup-entity this attrs true get-cb)] + (let [{:keys [:Entity/get-cb ::after-lookup]} (meta this) + v (lookup-entity this attrs true get-cb after-lookup)] (when get-cb (get-cb [this attrs v])) v))) -(defn q-entity-array [query conn & args] - (->> (apply d/q query conn args) - (map (fn id->entity [[id]] - (new-entity (d/entity conn id) nil))) - to-array)) - (defn transact! ([conn tx] (transact! conn tx nil)) ([conn tx tx-meta] @@ -359,7 +367,13 @@ (defn q [query conn & args] (humanize-error humanize-q-error - #(apply q-entity-array (js->query query) @conn (keywordize args)))) + #(apply d/q (js->query query) @conn (keywordize args)))) + +(defn ids->entities [db ids] + (->> ids + (map (fn id->entity [[id]] + (new-entity (d/entity db id) nil))) + to-array)) (defn humanize-get-error [error entity] (condp re-find (goog.object/get error "message") diff --git a/src/homebase/react.cljs b/src/homebase/react.cljs index 70dd95b7..67ea0e7e 100644 --- a/src/homebase/react.cljs +++ b/src/homebase/react.cljs @@ -6,12 +6,12 @@ [goog.object] [clojure.set] [homebase.js :as hbjs] + [homebase.cache :as hbc] + [nano-id.core :refer [nano-id]] [datascript.core :as d] [datascript.impl.entity :as de] [homebase.datalog-console :as datalog-console])) - - (defn try-hook [hook-name f] (if hbjs/*debug* (f) @@ -28,85 +28,6 @@ (second) (clojure.string/trim)))))))))) -(defn debug-msg [return-value & msgs] - (when (and (number? hbjs/*debug*) (>= hbjs/*debug* 2)) - (apply js/console.log "%c homebase-react " "background: yellow" msgs)) - return-value) - -(defn changed? [entities cached-entities track-count?] - (cond - (and track-count? - (not= (count entities) (count cached-entities))) - (debug-msg true "cache:miss" "count of entities != cache" - #js {:entities (clj->js entities) - :cache (clj->js cached-entities)}) - - (and track-count? - (not (clojure.set/superset? - (set (keys cached-entities)) - (set (map #(get % "id") entities))))) - (debug-msg true "cache:miss" "cache not superset of entities" - #js {:entities (clj->js entities) - :cache (clj->js cached-entities)}) - - :else - (reduce - (fn [_ e] - (when (let [id (get e "id") - cached-e (get cached-entities id)] - (if (nil? cached-e) - (if-not id - (reduced false) ; This entity has probably been removed, do not force a rerender - (reduced (debug-msg true "cache:miss" "not in cache" - #js {:entity-id id - :entities (clj->js entities) - :cache (clj->js cached-entities)}))) - (reduce (fn [_ [ks old-v]] - (let [e-without-cache (hbjs/Entity. ^de/Entity (.-_entity e) nil nil nil nil) - new-v (.apply (.-get e-without-cache) e-without-cache (into-array ks))] - (when (and (not= 0 (compare old-v new-v)) - ;; Ignore Entities and arrays of Entities - (not (or (instance? hbjs/Entity new-v) - (and (array? new-v) - (= (count new-v) (count old-v)) - (instance? hbjs/Entity (nth new-v 0)))))) - (reduced (debug-msg true "cache:miss" "value changed" - #js {:entity-id id - :attr-path (clj->js ks) - :e e - :old-v old-v - :new-v new-v - :entities (clj->js entities) - :cache (clj->js cached-entities)}))))) - nil cached-e))) - (reduced true))) - nil entities))) - -(defn cache->js [entity cached-entities] - (reduce - (fn [acc [ks v]] - (goog.object/set acc (str (to-array ks)) v) - acc) - #js {} (get @cached-entities (get entity "id")))) - -(defn touch-entity-cache [entity cached-entities] - (let [get-cb (fn [[e ks v]] - (if (get e "id") - (do - (swap! cached-entities assoc-in [(get e "id") ks] v) - (when hbjs/*debug* - (set! ^js/Object (.-_recentlyTouchedAttributes entity) - (cache->js e cached-entities)))) - (do - (reset! cached-entities {}) - (when hbjs/*debug* - (set! ^js/Object (.-_recentlyTouchedAttributes entity) #js {}))))) - _ (when hbjs/*debug* (set! ^js/Object (.-_recentlyTouchedAttributes entity) #js {})) - ; Use (set! ...) instead of (vary-meta) to preserve the reference to the original entity - ;; entity (vary-meta entity merge {:Entity/get-cb get-cb}) - _ (set! ^hbjs/Entity (.-_meta entity) {:Entity/get-cb get-cb})] - entity)) - (defn datom-select-keys [d] #js [(:e d) (str (:a d)) (:v d) (:tx d) (:added d)]) @@ -117,7 +38,7 @@ ;; (defn datoms->json [datoms] ;; (reduce -;; (fn [acc {:keys [e a v]}] +;; (fn [acc {:keys [e a v]}] ;; (assoc-in acc [e (namespace a) (name a)] v)) ;; {} datoms)) @@ -134,88 +55,158 @@ initial-tx (goog.object/getValueByKeys props #js ["config" "initialData"]) debug (goog.object/getValueByKeys props #js ["config" "debug"]) _ (when debug (set! hbjs/*debug* debug)) - conn (d/create-conn (if schema - (merge (hbjs/js->schema schema) base-schema) - base-schema))] - (datalog-console/init! {:conn conn}) - (when initial-tx (hbjs/transact! conn initial-tx)) - (react/createElement - (goog.object/get homebase-context "Provider") - #js {:value conn} - (goog.object/get props "children")))) + [initializing? setInitializing?] (react/useState true) + db-conn (react/useMemo + #(d/create-conn (if schema + (merge (hbjs/js->schema schema) base-schema) + base-schema)) + #js []) + cache-conn (react/useMemo + #(hbc/create-conn) + #js [])] + (react/useEffect + (fn [] + (hbc/connect! cache-conn db-conn) + (datalog-console/init! {:conn db-conn}) + (when initial-tx (hbjs/transact! db-conn initial-tx)) + (setInitializing? false) + #(hbc/disconnect! db-conn)) + #js []) + (if initializing? + "" + (react/createElement + (goog.object/get homebase-context "Provider") + #js {:value #js {:db-conn db-conn :cache-conn cache-conn}} + (goog.object/get props "children"))))) (defn ^:export useClient [] - (let [conn (react/useContext homebase-context) + (let [{:strs [db-conn]} (js->clj (react/useContext homebase-context)) key (react/useMemo rand #js []) client (react/useMemo (fn [] - #js {"dbToString" #(pr-str @conn) - "dbFromString" #(do (reset! conn (cljs.reader/read-string %)) - (d/transact! conn [] ::silent)) - "dbToDatoms" #(datoms->js (d/datoms @conn :eavt)) - ;; "dbToJSON" #(clj->js (datoms->json (d/datoms @conn :eavt))) - "entity" (fn [lookup] (js/Promise.resolve (hbjs/entity conn lookup))) - "query" (fn [query & args] (js/Promise.resolve (apply hbjs/q query conn args))) - "transactSilently" (fn [tx] (try-hook "useClient" #(hbjs/transact! conn tx ::silent))) - "addTransactListener" (fn [listener-fn] (d/listen! conn key #(when (not= ::silent (:tx-meta %)) + #js {"dbToString" #(pr-str @db-conn) + "dbFromString" #(do (reset! db-conn (cljs.reader/read-string %)) + (d/transact! db-conn [] ::silent)) + "dbToDatoms" #(datoms->js (d/datoms @db-conn :eavt)) + ;; "dbToJSON" #(clj->js (datoms->json (d/datoms @db-conn :eavt))) + "entity" (fn [lookup] (js/Promise.resolve (hbjs/entity db-conn lookup))) + "query" (fn [query & args] (js/Promise.resolve (apply hbjs/q query db-conn args))) + "transactSilently" (fn [tx] (try-hook "useClient" #(hbjs/transact! db-conn tx ::silent))) + "addTransactListener" (fn [listener-fn] (d/listen! db-conn key #(when (not= ::silent (:tx-meta %)) (listener-fn (datoms->js (:tx-data %)))))) - "removeTransactListener" #(d/unlisten! conn key)}) + "removeTransactListener" #(d/unlisten! db-conn key)}) #js [])] - [client])) - + #js [client])) + (defn ^:export useEntity [lookup] - (let [conn (react/useContext homebase-context) - cached-entities (react/useMemo #(atom {}) #js []) - run-lookup (react/useCallback - (fn run-lookup [] - (touch-entity-cache - (try-hook "useEntity" #(hbjs/entity conn lookup)) - cached-entities)) - #js [lookup]) - [result setResult] (react/useState (run-lookup)) - listener (react/useCallback - (fn entity-listener [] - (let [result (run-lookup)] - (when (changed? #js [result] @cached-entities false) - (setResult result)))) - #js [run-lookup])] + (let [{:strs [db-conn cache-conn]} (js->clj (react/useContext homebase-context)) + hbjs-entity (try-hook "useEntity" #(hbjs/entity db-conn lookup)) + reactive-lookup-uid (react/useMemo #(nano-id) #js []) + tracked-ea-pairs (react/useMemo #(atom #{}) #js []) + [result setResult] (react/useState hbjs-entity) + after-lookup (react/useCallback + (fn after-lookup [{:keys [^de/Entity entity attr]}] + (swap! tracked-ea-pairs conj [(:db/id entity) attr]) + (swap! cache-conn hbc/assoc-ea [(:db/id entity) attr] reactive-lookup-uid + (fn change-handler [] + (let [entity (try-hook "useEntity" #(hbjs/entity db-conn lookup)) + _ (set! ^hbjs/Entity (.-_meta entity) + {:homebase.js/after-lookup (:homebase.js/after-lookup (meta result))})] + (setResult entity)))) + #_(js/console.log "after lookup" lookup (:db/id entity) attr @cache-conn)) + #js [result setResult lookup]) + _ (set! ^hbjs/Entity (.-_meta hbjs-entity) + {:homebase.js/after-lookup after-lookup})] (react/useEffect - (fn use-entity-effect [] - (let [key (rand)] - (d/listen! conn key listener) - #(d/unlisten! conn key))) - #js [lookup]) - [result])) + (fn [] + #(doseq [ea @tracked-ea-pairs] + (swap! cache-conn hbc/dissoc-ea ea reactive-lookup-uid) + #_(js/console.log "dissoc-ea" ea @cache-conn))) + #js []) + #js [result])) (defn ^:export useQuery [query & args] - (let [conn (react/useContext homebase-context) - cached-entities (react/useMemo #(atom {}) #js []) - run-query (react/useCallback - (fn run-query [] - (let [result (try-hook "useQuery" #(apply hbjs/q query conn args))] - (when (and (not= (count result) (count @cached-entities)) - (not= 0 (count result))) - (reset! cached-entities {})) - (.map result (fn [e] (touch-entity-cache e cached-entities))))) - #js [query args]) - [result setResult] (react/useState (run-query)) - listener (react/useCallback - (fn query-listener [] - (let [result (run-query)] - (when (changed? result @cached-entities true) - (setResult result)))) - #js [run-query])] + (let [{:strs [db-conn cache-conn]} (js->clj (react/useContext homebase-context)) + reactive-lookup-uid (react/useMemo #(nano-id) #js [query args]) + q-result (atom (try-hook "useQuery" #(apply hbjs/q query db-conn args))) + tracked-ea-pairs (react/useMemo #(atom #{}) #js []) + [result setResult] (react/useState (hbjs/ids->entities @db-conn @q-result)) + after-lookup (react/useCallback + (fn after-lookup [{:keys [^de/Entity entity attr]}] + (swap! tracked-ea-pairs conj [(:db/id entity) attr]) + (swap! cache-conn hbc/assoc-ea [(:db/id entity) attr] reactive-lookup-uid + (fn change-handler [] + (let [entity (try-hook "useEntity" #(hbjs/entity db-conn lookup)) + _ (set! ^hbjs/Entity (.-_meta entity) + {:homebase.js/after-lookup (:homebase.js/after-lookup (meta result))})] + (setResult entity)))) + #_(js/console.log "after lookup" lookup (:db/id entity) attr @cache-conn)) + #js [result setResult lookup])] (react/useEffect - (fn use-query-effect [] - (let [key (rand)] - (d/listen! conn key listener) - #(d/unlisten! conn key))) - #js [query args]) - [result])) + (fn [] + (swap! cache-conn hbc/assoc-q query reactive-lookup-uid + (fn change-handler [] + (let [q-r (try-hook "useQuery" #(apply hbjs/q query db-conn args))] + (when (not= q-r @q-result) + (reset! q-result q-r) + (setResult (hbjs/ids->entities @db-conn @q-result)))))) + #(swap! cache-conn hbc/dissoc-q query reactive-lookup-uid)) + #js []) + #js [result])) + +;; (defn ^:export useEntity [lookup] +;; (let [{:strs [db-conn]} (js->clj (react/useContext homebase-context)) +;; cached-entities (react/useMemo #(atom {}) #js []) +;; run-lookup (react/useCallback +;; (fn run-lookup [] +;; (touch-entity-cache +;; (try-hook "useEntity" #(hbjs/entity db-conn lookup)) +;; cached-entities)) +;; #js [lookup]) +;; [result setResult] (react/useState (run-lookup)) +;; listener (react/useCallback +;; (fn entity-listener [] +;; (let [result (run-lookup)] +;; (when (changed? #js [result] @cached-entities false) +;; (setResult result)))) +;; #js [run-lookup])] +;; (react/useEffect +;; (fn use-entity-effect [] +;; (let [key (rand)] +;; (d/listen! db-conn key listener) +;; #(d/unlisten! db-conn key))) +;; #js [lookup]) +;; #js [result])) + +;; (defn ^:export useQuery [query & args] +;; (let [{:strs [db-conn]} (js->clj (react/useContext homebase-context)) +;; cached-entities (react/useMemo #(atom {}) #js []) +;; run-query (react/useCallback +;; (fn run-query [] +;; (let [result (try-hook "useQuery" #(apply hbjs/q query db-conn args))] +;; (when (and (not= (count result) (count @cached-entities)) +;; (not= 0 (count result))) +;; (reset! cached-entities {})) +;; (.map result (fn [e] (touch-entity-cache e cached-entities))))) +;; #js [query args]) +;; [result setResult] (react/useState (run-query)) +;; listener (react/useCallback +;; (fn query-listener [] +;; (let [result (run-query)] +;; (when (changed? result @cached-entities true) +;; (setResult result)))) +;; #js [run-query])] +;; (react/useEffect +;; (fn use-query-effect [] +;; (let [key (rand)] +;; (d/listen! db-conn key listener) +;; #(d/unlisten! db-conn key))) +;; #js [query args]) +;; #js [result])) (defn ^:export useTransact [] - (let [conn (react/useContext homebase-context) - transact (react/useCallback - (fn transact [tx] (try-hook "useTransact" #(hbjs/transact! conn tx))) + (let [{:strs [db-conn]} (js->clj (react/useContext homebase-context)) + transact (react/useCallback + (fn transact [tx] (try-hook "useTransact" #(hbjs/transact! db-conn tx))) #js [])] - [transact])) \ No newline at end of file + #js [transact])) diff --git a/src/homebase/reagent.cljs b/src/homebase/reagent.cljs new file mode 100644 index 00000000..f697a676 --- /dev/null +++ b/src/homebase/reagent.cljs @@ -0,0 +1,119 @@ +(ns homebase.reagent + (:require + [homebase.cache :as hbc] + [datalog-console.chrome.formatters] ; Load the formatters ns to extend cljs-devtools to better render db entities in the chrome console if cljs-devtools is enabled. + [devtools.protocols :as dtp :refer [IFormat]] + [datascript.impl.entity :as de] + [reagent.core :as r] + [nano-id.core :refer [nano-id]] + [datascript.core :as d])) + +(declare lookup-entity) + +(deftype Entity [^de/Entity entity meta] + IFormat + (-header [_] (dtp/-header entity)) + (-has-body [_] (dtp/-has-body entity)) + (-body [_] (dtp/-body entity)) + IMeta + (-meta [_] meta) + IWithMeta + (-with-meta [_ new-meta] (Entity. entity new-meta)) + ILookup + (-lookup [this attr] (lookup-entity this attr nil)) + (-lookup [this attr not-found] (lookup-entity this attr not-found)) + IAssociative + (-contains-key? [this k] (not= ::nf (lookup-entity this k ::nf))) + IFn + (-invoke [this k] (lookup-entity this k nil)) + (-invoke [this k not-found] (lookup-entity this k not-found))) + +(defn lookup-entity [^Entity entity attr not-found] + (let [result (de/lookup-entity ^de/Entity (.-entity entity) attr not-found) + after-lookup (::after-lookup (meta entity))] + (when after-lookup (after-lookup {:entity (.-entity entity) :attr attr :result result})) + (cond + (instance? de/Entity result) + (Entity. result {::after-lookup after-lookup}) + + (and (set? result) (instance? de/Entity (first result))) + (set (map #(Entity. % {::after-lookup after-lookup}) result)) + + :else result))) + +(defn connect! + "Connects a db-conn to a homebase.cache. This is a prerequisite for any of the db read functions in this namespace to be reactive. Returns a homebase.cache connection." + [db-conn] + (let [cache-conn (hbc/create-conn)] + (hbc/connect! cache-conn db-conn) + {:cache-conn cache-conn})) + +(defn disconnect! [db-conn] + (hbc/disconnect! db-conn)) + +(defn get-cache-conn-from-db [db] + (let [cache-conn (:homebase.cache/conn (meta db)) + _ (when (not cache-conn) + (throw (ex-info "Cache not connected. Connect your db to the cache with (homebase.reagent/connect! db-conn) first." + {})))] + cache-conn)) + +(defn make-reactive-entity [{:keys [^de/Entity entity r-entity tracked-ea-pairs db-conn cache-conn reactive-lookup-uid] :as args}] + (let [top-level-entity-id (:db/id entity) + e (Entity. entity {::after-lookup + (fn after-lookup [{:keys [^de/Entity entity attr]}] + (swap! tracked-ea-pairs conj [(:db/id entity) attr]) + (swap! cache-conn hbc/assoc-ea [(:db/id entity) attr] reactive-lookup-uid + (fn change-handler [] + (reset! r-entity + (make-reactive-entity + (merge args {:entity (d/entity @db-conn top-level-entity-id)}))))) + #_(js/console.log top-level-entity-id (:db/id entity) attr @cache-conn))})] + e)) + +(defn entity + "Returns a reactive homebase.reagent/Entity wrapped in a vector. + + It offers a normalized subset of other entity APIs with the + primary addition being that implemented protocols are reactive + and trigger re-renders when related datoms change. + + NOTE: This takes a conn, not a db." + [db-conn lookup] + (let [cache-conn (get-cache-conn-from-db @db-conn) + entity (d/entity @db-conn lookup) + reactive-lookup-uid (nano-id) + tracked-ea-pairs (atom #{}) + r-entity (r/atom nil) + hbr-entity (make-reactive-entity {:entity entity :r-entity r-entity :tracked-ea-pairs tracked-ea-pairs :db-conn db-conn :cache-conn cache-conn :reactive-lookup-uid reactive-lookup-uid}) + _ (reset! r-entity hbr-entity) + f (fn [] + (r/with-let [] + @r-entity + (finally ; handle unmounting this component + (doseq [ea @tracked-ea-pairs] + (swap! cache-conn hbc/dissoc-ea ea reactive-lookup-uid) + #_(js/console.log ea @cache-conn)))))] + [(r/track f)])) + +(defn q + "Returns a reactive query result wrapped in a vector. + + It will trigger a re-render when its result changes. + + NOTE: This takes a conn, not a db." + [query db-conn & inputs] + (let [cache-conn (get-cache-conn-from-db @db-conn) + result (apply d/q query @db-conn inputs) + r-result (r/atom result) + reactive-lookup-uid (nano-id) + _ (swap! cache-conn hbc/assoc-q query reactive-lookup-uid + (fn [] + (reset! r-result (apply d/q query @db-conn inputs)))) + f (fn [] + (r/with-let [] + @r-result + (finally ; handle unmounting this component + (swap! cache-conn hbc/dissoc-q query reactive-lookup-uid) + #_(js/console.log query @cache-conn))))] + [(r/track f)])) \ No newline at end of file