diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..f56af6f --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,95 @@ +name: 'cd' + +on: + workflow_dispatch: + push: + branches: + - master + paths: + - 'project.clj' + +permissions: + contents: read + # Needed for the 'trilom/file-changes-action' action + pull-requests: read + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + build: + runs-on: ${{ fromJSON('["ubuntu-latest", "self-hosted"]')[github.repository == 'github/docs-internal'] }} + steps: + + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup java + uses: actions/setup-java@v3 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '11' + + - name: Display (print) node and java versions + run: + node -v && java --version + + - name: Install clojure tools + uses: DeLaGuardo/setup-clojure@9.5 + with: + # Install just one or all simultaneously + # The value must indicate a particular version of the tool, or use 'latest' + # to always provision the latest version + cli: 1.11.1.1149 # Clojure CLI based on tools.deps + lein: 2.9.8 # Leiningen + clj-kondo: 2022.05.31 # Clj-kondo + + - name: Get clj-kondo version. Clj-kondo searches for opportunities of optimizations + run: clj-kondo --version + + # # Optional step: + # - name: Cache clojure dependencies + # uses: actions/cache@v3 + # with: + # path: | + # ~/.m2/repository + # ~/.gitlibs + # ~/.deps.clj + # # List all files containing dependencies: + # key: cljdeps-${{ hashFiles('deps.edn') }} + # # key: cljdeps-${{ hashFiles('deps.edn', 'bb.edn') }} + # # key: cljdeps-${{ hashFiles('project.clj') }} + # # key: cljdeps-${{ hashFiles('build.boot') }} + # restore-keys: cljdeps- + + - name: Test if vanilla clojure code is working + run: clojure -e "(+ 1 1)" + + - name: Display Leiningen version + run: lein -v + + # - name: Run cljfmt formatter + # run: lein cljfmt check + + # - name: Run clj-kondo + # run: clj-kondo --lint src + + - name: lein install on this version of firebase-re-frame + run: + lein install + + # - name: Run lein tests + # run: lein test + + - name: Deploy to Github Package Registry + env: + GITHUB_TOKEN: ${{ secrets.RE_FRAME_FIREBASE_REPOSITORY_SECRET_ACTIONS }} + run: | + mkdir -p ~/.m2 + echo "github$(echo '$GITHUB_REPOSITORY' | awk -F / '{print $1}')\${env.GITHUB_TOKEN}" > ~/.m2/settings.xml + REPO="gh::default::https://maven.pkg.github.com/${GITHUB_REPOSITORY}" + mvn deploy -DaltReleaseDeploymentRepository="${REPO}" -DaltSnapshotDeploymentRepository="${REPO}" diff --git a/.gitignore b/.gitignore index d4a3ab9..5a2d640 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ checkouts/ pom.xml .firebaserc + +# MacOS system file +.DS_Store diff --git a/README.md b/README.md index 38e9891..f2e6b41 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ See [Firebase docs][phone-auth] for details. The firebase database is a tree. You can write values to nodes in a tree, or push them to auto-generated unique sub-nodes of a node. In re-frame-firebase, these are exposed -through the `:firebase/write` `:firebase/push` and `:firebase/update` effect handlers. +through the `:firebase/write` and `:firebase/push` effect handlers. Each takes parameters: - `:path` - A vector representing a node in the firebase tree, e.g. `[:my :node]` @@ -269,7 +269,10 @@ Each takes parameters: - `:on-success` - Event vector or function to call when write succeeds. - `:on-failure` - Event vector or function to call with the error. -Example: +There are also the atomic `:firebase/update`, `:firebase/transaction`, and +`:firebase/swap` effect handlers, discussed below. + +Write example: ```clojure (re-frame/reg-event-fx @@ -299,15 +302,19 @@ Example (diff in bold): > **Note:** Events will also receive the same creation key. `(rf/reg-event-fx :event-name (fn [ctx [_ key]])` -`:firebase/update` can write to children subnodes, without overwriting children's siblings. Use +`:firebase/update` can write to children and decendant subnodes, without overwriting siblings. Use a clojure map of children subnode(s) and their value(s). `:firebase/write` overwrites all children. +NB: to update second or deeper level decendants without disturbing siblings, use a +string-delimited-with-slash (/) key. See the "universe/subuniverse" +example below. + Example (diff in bold):
 (re-frame/reg-event-fx
   :update-status
-  (fn [{db :db} [_ status-children]]   ;; status-children is e.g. {:life 42, :universe 42, :everything 42}
+  (fn [{db :db} [_ status-children]]   ;; status-children is e.g. {:life 42, "universe/subuniverse" 42, :everything 42}
     {:firebase/update {:path [:status]
                       :value status-children
                       :on-success #(js/console.log "Updated status-children")
@@ -318,8 +325,57 @@ Example (diff in bold):
 
 [multi-location-update-blogpost]: https://firebase.googleblog.com/2015/09/introducing-multi-location-updates-and_86.html
 
-Re-frame-firebase also supplies `:firebase/multi` to allow multiple write and/or
-pushes from a single event:
+The `:firebase/transaction` effect handler and its more Clojure-y variant
+`:firebase/swap` perform atomic modifications to the tree.
+
+In `:firebase/transaction`, the `:transaction-update` parameter is a function that takes
+one parameter that is the old value at the `:path` location and returns the new
+value. Note that the function may be called multiple times, so should be free of side
+effects.
+
+The function must also tolerate a `nil` input gracefully. To abort a transaction, say to
+avoid overwriting an existing value, the function returns `js/undefined`.
+
+Finally, note that the `:apply-locally` boolean indicates whether the local
+firebase-system cached value should be applied optimistically, which may result in more
+than one update event to be emitted if the function needs to be run more than once. The
+default value is `true`.
+
+```clojure
+{:firebase/transaction {:path [:my :data]
+                  :transaction-update (fn [old-val] (if old-val (inc old-val)))
+                  :apply-locally false  ;; default is true = multiple update events may be received if transaction-update needs to be run more than once.
+                  ;; The on-* handlers can also take a re-frame event 
+                  :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot)))
+                  :on-failure (fn [err snapshot committed] (prn "Error: " err))}}
+```
+
+`:firebase/swap` is similar to `:firebase/transaction` but takes an `:argv` argument,
+typically a vector. The argument `:f` is the renamed `:transaction-update`, the
+update function.  The old value at the `:path` is prepended to `:argv` and then `:f`
+is applied much like `clojure.core\swap!` does for atoms.
+
+Both atomic effect handlers are provided to appeal to users coming from Firebase-first
+or Clojure-first backgrounds, respectively. For those coming from Firebase, note that
+the `snapshot` and `committed` parameters are reversed in on-*. This is to facilitate
+re-frame event handlers as they receive only the first passed parameter, ignoring the
+rest. Passing snapshot rather than committed makes for more useful possibilities.
+
+Example (diff in bold):
+
+
+{:firebase/swap {:path [:my :data]
+                  :f + 
+                  :argv [2 3] ;; So the swap will perform (+ old-value 2 3) 
+                  :apply-locally false  ;; default is true = multiple update events may be received if transaction-update needs to be run more than once.
+                  ;; The on-* handlers can also take a re-frame event 
+                  :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot)))
+                  :on-failure [:handle-failure]}}
+
+ + +Re-frame-firebase also supplies `:firebase/multi` to allow multiple writes and other +effects from a single event: ```clojure (re-frame/reg-event-fx diff --git a/project.clj b/project.clj index eaae33b..95a5a93 100644 --- a/project.clj +++ b/project.clj @@ -1,20 +1,42 @@ ;;; Author: David Goldfarb (deg@degel.com) ;;; Copyright (c) 2017-8, David Goldfarb -(defproject com.degel/re-frame-firebase "0.9.0-SNAPSHOT" +(defproject tallyfor/re-frame-firebase "0.10.3" :description "A re-frame wrapper around firebase" :url "https://github.com/deg/re-frame-firebase" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.10.0"] [org.clojure/clojurescript "1.10.439"] - [cljsjs/firebase "5.7.3-1"] + [cljsjs/firebase "7.5.0-0"] ;;"5.7.3-1" [re-frame "0.10.6"] - [com.degel/iron "0.4.0"]] + [com.degel/iron "0.4.0"] + [lein-pprint "1.3.2"] + [lein-cljsbuild "1.1.8"] + [lein-bump-version "0.1.6"]] + ;; run lein install with LEIN_SNAPSHOTS_IN_RELEASE=true lein install + :lein-tools-deps/config {:config-files [:install :user :project]} + :jvm-opts ^:replace ["-Xmx1g" "-server"] :cljsbuild {:builds {}} ; prevent https://github.com/emezeske/lein-cljsbuild/issues/413 :plugins [[lein-npm "0.6.2"]] :npm {:dependencies [[source-map-support "0.5.6"]]} + :profiles {:dev {:dependencies [[clj-stacktrace "0.2.8"] + [binaryage/devtools "0.9.10"] + [org.clojure/tools.namespace "1.1.0"]]}} :source-paths ["src" "target/classes"] + ;; Change your environment variables (maybe editing .zshrc or .bashrc) to have: + ;; export LEIN_USERNAME="pdelfino" + ;; export LEIN_PASSWORD="your-personal-access-token-the-same-used-on-.npmrc" + ;; LEIN_PASSWORD should use the same Token used by .npmrc + ;; Also, do "LEIN_SNAPSHOTS_IN_RELEASE=true lein install" or edit your .zshrc: + ;; export LEIN_SNAPSHOTS_IN_RELEASE=true + :repositories {"releases" {:url "https://maven.pkg.github.com/tallyfor/*" + :username :env/LEIN_USERNAME ;; change your env + :password :env/LEIN_PASSWORD}} + + :pom-addition [:distribution-management [:repository [:id "github"] + [:name "GitHub Packages"] + [:url "https://maven.pkg.github.com/tallyfor/re-frame-firebase"]]] :clean-targets ["out" "release"] :target-path "target") diff --git a/src/com/degel/re_frame_firebase.cljs b/src/com/degel/re_frame_firebase.cljs index e71dbc8..cbb0527 100644 --- a/src/com/degel/re_frame_firebase.cljs +++ b/src/com/degel/re_frame_firebase.cljs @@ -11,7 +11,8 @@ [com.degel.re-frame-firebase.core :as core] [com.degel.re-frame-firebase.auth :as auth] [com.degel.re-frame-firebase.database :as database] - [com.degel.re-frame-firebase.firestore :as firestore])) + [com.degel.re-frame-firebase.firestore :as firestore] + [com.degel.re-frame-firebase.storage :as storage])) ;;; Write a value to Firebase. ;;; See https://firebase.google.com/docs/reference/js/firebase.database.Reference#set @@ -35,6 +36,28 @@ ;;; (re-frame/reg-fx :firebase/update database/update-effect) +;;; Transactionally reads and writes a value to Firebase. NB: :transaction-update function +;;; may run more than once so must be free of side effects. Importantly, it must be able +;;; to handle null data. To abort a transaction, return js/undefined. +;;; See https://firebase.google.com/docs/reference/js/firebase.database.Reference#transaction +;;; +;;; Examples FX: +;;; {:firebase/transaction {:path [:my :data] +;;; :transaction-update (fn [old-val] (if old-val (inc old-val))) +;;; :apply-locally false ;; default is true = multiple update events may be received if transaction-update needs to be run more than once. +;;; ;; The on-* handlers can also take a re-frame event +;;; :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot))) +;;; :on-failure (fn [err snapshot committed] (prn "Error: " err))}} +;;; +;;; {:firebase/swap {:path [:my :data] +;;; :f + +;;; :argv [2 3] +;;; :apply-locally false ;; default is true = multiple update events may be received if transaction-update needs to be run more than once. +;;; ;; The on-* handlers can also take a re-frame event +;;; :on-success (fn [snapshot committed] (if committed (prn "Transaction committed: " snapshot))) +;;; :on-failure [:firebase-error]}} +(re-frame/reg-fx :firebase/transaction database/transaction-effect) +(re-frame/reg-fx :firebase/swap database/swap-effect) ; A synonym with :argv for update function :f ;;; Write a value to a Firebase list. ;;; See https://firebase.google.com/docs/reference/js/firebase.database.Reference#push @@ -74,6 +97,8 @@ :firebase/write (database/write-effect args) :firebase/update (database/update-effect args) :firebase/push (database/push-effect args) + :firebase/transaction (database/transaction-effect args) + :firebase/swap (database/swap-effect args) :firebase/read-once (database/once-effect args) :firestore/delete (firestore/delete-effect args) :firestore/set (firestore/set-effect args) @@ -120,10 +145,11 @@ ;;; "https://www.googleapis.com/auth/calendar.readonly"] ;;; :custom-parameters {"login_hint" "user@example.com"}}} ;;; -(re-frame/reg-fx :firebase/google-sign-in auth/google-sign-in) -(re-frame/reg-fx :firebase/facebook-sign-in auth/facebook-sign-in) -(re-frame/reg-fx :firebase/twitter-sign-in auth/twitter-sign-in) -(re-frame/reg-fx :firebase/github-sign-in auth/github-sign-in) +(re-frame/reg-fx :firebase/google-sign-in auth/google-sign-in) +(re-frame/reg-fx :firebase/facebook-sign-in auth/facebook-sign-in) +(re-frame/reg-fx :firebase/twitter-sign-in auth/twitter-sign-in) +(re-frame/reg-fx :firebase/github-sign-in auth/github-sign-in) +(re-frame/reg-fx :firebase/microsoft-sign-in auth/microsoft-sign-in) ;;; Login to firebase using email/password authentication @@ -360,6 +386,11 @@ ;;; (re-frame/reg-sub-raw :firestore/on-snapshot firestore/on-snapshot-sub) +;;; Firebase Storage, an online object store, different from the similarly named Firestore. + +(re-frame/reg-fx :storage/put storage/put-effect) +(re-frame/reg-fx :storage/delete storage/delete-effect) + ;;; Start library and register callbacks. ;;; diff --git a/src/com/degel/re_frame_firebase/auth.cljs b/src/com/degel/re_frame_firebase/auth.cljs index f459f27..4e1aacd 100644 --- a/src/com/degel/re_frame_firebase/auth.cljs +++ b/src/com/degel/re_frame_firebase/auth.cljs @@ -75,7 +75,6 @@ (>evt [(core/default-error-handler) (js/Error. (str "Unsupported sign-in-method: " sign-in-method ". Either :redirect or :popup are supported."))])))) - (defn google-sign-in [opts] ;; TODO: use Credential for mobile. @@ -97,6 +96,12 @@ (oauth-sign-in (js/firebase.auth.GithubAuthProvider.) opts)) +(defn microsoft-sign-in + [opts] + (oauth-sign-in (js/firebase.auth.OAuthProvider. "microsoft.com") opts)) + + + (defn email-sign-in [{:keys [email password]}] (-> (js/firebase.auth) (.signInWithEmailAndPassword email password) diff --git a/src/com/degel/re_frame_firebase/database.cljs b/src/com/degel/re_frame_firebase/database.cljs index 2208695..48e18e4 100644 --- a/src/com/degel/re_frame_firebase/database.cljs +++ b/src/com/degel/re_frame_firebase/database.cljs @@ -3,18 +3,18 @@ (ns com.degel.re-frame-firebase.database (:require - [clojure.spec.alpha :as s] - [clojure.string :as str] - [re-frame.core :as re-frame] - [re-frame.loggers :refer [console]] - [reagent.ratom :as ratom :refer [make-reaction]] - [iron.re-utils :refer [evt event->fn sub->fn]] - [iron.utils :as utils] - [firebase.app :as firebase-app] - [firebase.database :as firebase-database] - [com.degel.re-frame-firebase.helpers :refer [js->clj-tree success-failure-wrapper]] - [com.degel.re-frame-firebase.core :as core] - [com.degel.re-frame-firebase.specs :as specs])) + [clojure.spec.alpha :as s] + [clojure.string :as str] + [re-frame.core :as re-frame] + [re-frame.loggers :refer [console]] + [reagent.ratom :as ratom :refer [make-reaction]] + [iron.re-utils :refer [evt event->fn sub->fn]] + [iron.utils :as utils] + [firebase.app :as firebase-app] + [firebase.database :as firebase-database] + [com.degel.re-frame-firebase.helpers :refer [js->clj-tree success-failure-wrapper]] + [com.degel.re-frame-firebase.core :as core] + [com.degel.re-frame-firebase.specs :as specs])) (s/def ::cache (s/nilable (s/keys))) @@ -38,6 +38,47 @@ (def ^:private update-effect updater) +(defn- transaction->js + [retval] + ;; Preserve js/undefined as it signals to abort the transaction. + ;; https://firebase.google.com/docs/reference/js/firebase.database.Reference#transaction + (if (= js/undefined retval) + retval + (clj->js retval))) + +(defn- transaction-update-wrapper [transaction-update] + (fn [old-value] + (-> old-value + js->clj + clojure.walk/keywordize-keys + transaction-update + transaction->js))) + +(defn- transactioner [{:keys [path transaction-update on-success on-failure apply-locally]}] + (try + (.transaction (fb-ref path) + (transaction-update-wrapper transaction-update) + (success-failure-wrapper on-success on-failure) + ;; Force apply-locally to be a boolean, as required by .transaction. + (if (or (false? apply-locally) + (nil? apply-locally)) + false + true)) + (catch :default e (on-failure e)))) + + +(def transaction-effect transactioner) + +(defn- swapper [{:keys [path f argv on-success on-failure apply-locally]}] + (transactioner + {:path path + :transaction-update (fn [old-val] (apply f old-val argv)) + :on-success on-success + :on-failure on-failure + :apply-locally apply-locally})) + +(def swap-effect swapper) + (defn push-effect [{:keys [path value on-success on-failure] :as all}] (let [key (.-key (.push (fb-ref path)))] (setter (assoc all @@ -68,19 +109,19 @@ callback #(>evt [::on-value-handler id (js->clj-tree %)])] (.on ref "value" callback (event->fn (or on-failure (core/default-error-handler)))) (make-reaction - (fn [] (get-in @app-db [::cache id] [])) - :on-dispose #(do (.off ref "value" callback) - (>evt [::on-value-handler id nil])))) + (fn [] (get-in @app-db [::cache id] nil)) + :on-dispose #(do (.off ref "value" callback) + (>evt [::on-value-handler id nil])))) (do (console :error "Received null Firebase on-value request") (make-reaction - (fn [] - ;; Minimal dummy response, to avoid blowing up caller - nil))))) + (fn [] + ;; Minimal dummy response, to avoid blowing up caller + nil))))) (re-frame/reg-event-db - ::on-value-handler - (fn [app-db [_ id value]] - (if value - (assoc-in app-db [::cache id] value) - (update app-db ::cache dissoc id)))) + ::on-value-handler + (fn [app-db [_ id value]] + (if value + (assoc-in app-db [::cache id] value) + (update app-db ::cache dissoc id)))) diff --git a/src/com/degel/re_frame_firebase/helpers.cljs b/src/com/degel/re_frame_firebase/helpers.cljs index de79167..3b7a222 100644 --- a/src/com/degel/re_frame_firebase/helpers.cljs +++ b/src/com/degel/re_frame_firebase/helpers.cljs @@ -29,14 +29,50 @@ (.catch promise (re-utils/event->fn on-failure)) (.catch promise (core/default-error-handler)))) - (defn success-failure-wrapper [on-success on-failure] {:pre [(utils/validate (s/nilable :re-frame/vec-or-fn) on-success) (utils/validate (s/nilable :re-frame/vec-or-fn) on-failure)] :post (fn? %)} (let [on-success (and on-success (re-utils/event->fn on-success)) - on-failure (and on-failure (re-utils/event->fn on-failure))] - (fn [err] - (cond (nil? err) (when on-success (on-success)) - on-failure (on-failure err) - :else ((core/default-error-handler) err))))) + on-failure (and on-failure (re-utils/event->fn on-failure)) + wrapped-handler (fn + ([err] (cond (nil? err) (when on-success (on-success)) + on-failure (on-failure err) + :else ((core/default-error-handler) err))) + + ;; I am unable to find in the Google Firebase documentation* a 2-arity + ;; callback for .set .update or .transaction that uses this wrapper. Yet, I've + ;; observed that such a callback exists specifically on .update. With + ;; trepidation arising from minimal ad hoc testing, I am forwarding the second + ;; parameter, assuming that this behavior was undetected and inconsequential before + ;; I wrote wrapped-handler to be multi-arity. + ;; + ;; [TODO] Find the reason for this 2-arity version and properly dispatch it. + ;; + ;; * https://firebase.google.com/docs/reference/js/firebase.database.Reference + ([err other] + (cond (nil? err) (when on-success (on-success other)) + on-failure (on-failure err other) + :else ((core/default-error-handler) err))) + + ;; onComplete invoked in :firebase/transaction and :firebase/swap accepts an + ;; error code, a boolean indicating committed status, and a snapshot of the + ;; data at that path. + ;; + ;; This is useful for exposing state changes upon completion of the + ;; transaction, as the transaction-update or f functions must be side-effect + ;; free. Notably here, we reverse the order of committed and snapshot in the + ;; cljs versions on-success and on-failure. So, if the on-success handler is + ;; a re-frame event vector (in iron.re-utils/re-utils they only take the first + ;; parameter), it gets the snapshotted data. An on-failure event handler + ;; would get the error code; it has snapshot and committed reversed for + ;; continuity. + ([err committed snapshot] + (cond (nil? err) (when on-success (on-success (js->clj-tree snapshot) committed)) + on-failure (on-failure err (js->clj-tree snapshot) committed) + :else ((core/default-error-handler) err))))] + wrapped-handler)) + + + + diff --git a/src/com/degel/re_frame_firebase/storage.cljs b/src/com/degel/re_frame_firebase/storage.cljs new file mode 100644 index 0000000..875e879 --- /dev/null +++ b/src/com/degel/re_frame_firebase/storage.cljs @@ -0,0 +1,43 @@ +(ns com.degel.re-frame-firebase.storage + (:require + [clojure.spec.alpha :as s] + [clojure.string :as str] + [re-frame.core :as re-frame] + [reagent.ratom :as ratom :refer [make-reaction]] + [iron.re-utils :as re-utils :refer [evt event->fn sub->fn]] + [iron.utils :as utils] + [firebase.app :as firebase-app] + [firebase.storage :as firebase-storage] + [com.degel.re-frame-firebase.core :as core] + [com.degel.re-frame-firebase.specs :as specs] + [com.degel.re-frame-firebase.helpers :refer [promise-wrapper]])) + + +;;; 1. Create a root reference +;;; 2. Create reference to end object +;;; 3. Upload blob/file + +(defn clj->StorageReference + "Converts path, a string/keyword or seq of string/keywords, into a StorageReference" + [path] + {:pre [(utils/validate ::specs/path path)]} + (if (instance? js/firebase.storage.Reference path) + path + (.child + (.ref (js/firebase.storage)) + (str/join "/" (clj->js path))))) + +(defn- putter + [path blob] + (.put (clj->StorageReference path) + blob)) + +(defn put-effect [{:keys [path data on-success on-failure]}] + (promise-wrapper (putter path data) on-success on-failure)) + +(defn- deleter + [path] + (.delete (clj->StorageReference path))) + +(defn delete-effect [{:keys [path on-success on-failure]}] + (promise-wrapper (deleter path) on-success on-failure))