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