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 "
(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))