diff --git a/deps/db/src/logseq/db/sqlite/backup.cljs b/deps/db/src/logseq/db/sqlite/backup.cljs index 1bcee037f9..6dda40705f 100644 --- a/deps/db/src/logseq/db/sqlite/backup.cljs +++ b/deps/db/src/logseq/db/sqlite/backup.cljs @@ -1,6 +1,7 @@ (ns logseq.db.sqlite.backup "Shared SQLite backup utilities for Node runtimes." - (:require ["node:sqlite" :as node-sqlite] + (:require ["node:fs" :as fs] + ["node:sqlite" :as node-sqlite] [clojure.string :as string] [goog.object :as gobj] [promesa.core :as p])) @@ -16,9 +17,6 @@ (throw (ex-info "node:sqlite DatabaseSync constructor missing" {:module-keys (js->clj (js/Object.keys node-sqlite))})))) -(def ^:private DatabaseSync - (resolve-database-sync-ctor)) - (defn- resolve-sqlite-backup-fn [] (or (gobj/get node-sqlite "backup") @@ -30,17 +28,31 @@ (def ^:private sqlite-backup-fn (resolve-sqlite-backup-fn)) +(defn- remove-file-if-exists! + [path] + (fs/rmSync path #js {:force true})) + (defn backup-connection! [^js db path] - (sqlite-backup-fn db path)) + (try + (-> (sqlite-backup-fn db path) + (p/catch (fn [error] + (remove-file-if-exists! path) + (throw error)))) + (catch :default error + (remove-file-if-exists! path) + (p/rejected error)))) (defn backup-db-file! - [src-path dst-path] - (let [db (new DatabaseSync src-path)] - (-> (backup-connection! db dst-path) - (p/finally (fn [] - (try - (.close db) - (catch :default error - (when-not (string/includes? (str error) "database is not open") - (throw error))))))))) + ([src-path dst-path] + (let [DatabaseSync (resolve-database-sync-ctor) + db (new DatabaseSync src-path)] + (-> (backup-connection! db dst-path) + (p/finally (fn [] + (try + (.close db) + (catch :default error + (when-not (string/includes? (str error) "database is not open") + (throw error))))))))) + ([db _src-path dst-path] + (backup-connection! db dst-path))) diff --git a/src/electron/electron/db.cljs b/src/electron/electron/db.cljs index 29789f8485..e5a118510a 100644 --- a/src/electron/electron/db.cljs +++ b/src/electron/electron/db.cljs @@ -3,8 +3,10 @@ (:require ["fs-extra" :as fs] ["path" :as node-path] [electron.backup-file :as backup-file] + [electron.db-worker :as db-worker] [lambdaisland.glogi :as log] [logseq.cli.common.graph :as cli-common-graph] + [logseq.cli.transport :as cli-transport] [logseq.common.graph-dir :as graph-dir] [logseq.db.common.sqlite :as common-sqlite] [logseq.db.sqlite.backup :as sqlite-backup] @@ -45,15 +47,15 @@ (rand-int 1000000) ".sqlite"))) -(defn backup-db! - [db-name {:keys [force-backup?]}] +(defn backup-db-with-sqlite-backup! + [db-name {:keys [force-backup? sqlite-backup!]}] (let [_ (ensure-graph-dir! db-name) [_db-name db-path] (common-sqlite/get-db-full-path (cli-common-graph/get-db-graphs-dir) db-name) backups-path (common-sqlite/get-db-backups-path (cli-common-graph/get-db-graphs-dir) db-name)] (when (fs/existsSync db-path) (let [tmp-path (temp-backup-path backups-path)] (-> (p/let [_ (fs/ensureDirSync backups-path) - _ (sqlite-backup/backup-db-file! db-path tmp-path) + _ (sqlite-backup! db-path tmp-path) payload (fs/readFileSync tmp-path)] (backup-file/backup-file db-name nil nil ".sqlite" @@ -67,19 +69,42 @@ (catch :default _ nil))))))))) -(defn- active-repos +(defn backup-db! + [db-name opts] + (backup-db-with-sqlite-backup! + db-name + (assoc opts :sqlite-backup! sqlite-backup/backup-db-file!))) + +(defn backup-db-via-worker! + [db-name window-id opts] + (backup-db-with-sqlite-backup! + db-name + (assoc opts + :sqlite-backup! + (fn [_src-path dst-path] + (p/let [runtime (db-worker/ensure-runtime! db-name window-id)] + (cli-transport/invoke runtime + :thread-api/backup-db-sqlite + false + [db-name dst-path])))))) + +(defn- active-repo-window-ids [] - (->> (:window->repo @*auto-backup) - vals - (remove nil?) - set - vec)) + (let [repo->window-ids (reduce-kv (fn [m window-id repo] + (if (seq repo) + (update m repo (fnil conj []) window-id) + m)) + {} + (:window->repo @*auto-backup))] + (mapv (fn [[repo window-ids]] + [repo (first window-ids)]) + repo->window-ids))) (defn run-auto-backup! [] (p/all - (for [repo (active-repos)] - (-> (backup-db! repo {}) + (for [[repo window-id] (active-repo-window-ids)] + (-> (backup-db-via-worker! repo window-id {}) (p/catch (fn [error] (log/warn :electron/auto-db-backup-failed {:repo repo @@ -89,7 +114,7 @@ (defn- reconcile-auto-backup-timer! [] (let [{:keys [interval-id]} @*auto-backup - has-repos? (seq (active-repos))] + has-repos? (seq (active-repo-window-ids))] (cond (and has-repos? (nil? interval-id)) (let [id (js/setInterval (fn [] (run-auto-backup!)) diff --git a/src/electron/electron/handler.cljs b/src/electron/electron/handler.cljs index a51c7b1ed5..8c50c9b598 100644 --- a/src/electron/electron/handler.cljs +++ b/src/electron/electron/handler.cljs @@ -234,10 +234,10 @@ (p/rejected (ex-info "repo is required" {:code :missing-repo})) (db-worker/ensure-runtime! (canonical-repo repo) (.-id window)))) -(defmethod handle :db-export [_window [_ repo force-backup?]] +(defmethod handle :db-export [window [_ repo force-backup?]] (when-let [repo (canonical-repo repo)] (db/ensure-graph-dir! repo) - (db/backup-db! repo {:force-backup? force-backup?}))) + (db/backup-db-via-worker! repo (.-id window) {:force-backup? force-backup?}))) (defmethod handle :db-get [_window [_ repo]] (when-let [repo (canonical-repo repo)] diff --git a/src/test/electron/db_test.cljs b/src/test/electron/db_test.cljs index 3e0fed732e..fc1cbb2d0a 100644 --- a/src/test/electron/db_test.cljs +++ b/src/test/electron/db_test.cljs @@ -114,6 +114,48 @@ (is false (str "unexpected error: " e)))) (p/finally done))))) +(deftest backup-db-with-sqlite-backup-uses-provided-snapshot-fn + (async done + (let [graphs-dir (node-helper/create-tmp-dir "electron-db-backup-custom-snapshot") + db-name "logseq_db_demo" + [_ db-path] (common-sqlite/get-db-full-path graphs-dir db-name) + backups-path (common-sqlite/get-db-backups-path graphs-dir db-name) + custom-calls (atom []) + fallback-calls (atom []) + backup-calls (atom [])] + (fs/ensureDirSync (node-path/dirname db-path)) + (fs/writeFileSync db-path "seed") + (-> (p/with-redefs [cli-common-graph/get-db-graphs-dir (fn [] graphs-dir) + sqlite-backup/backup-db-file! (fn [src dst] + (swap! fallback-calls conj [src dst]) + (p/rejected (ex-info "fallback should not run" {}))) + backup-file/backup-file (fn [repo _dir _relative-path ext content & {:as opts}] + (swap! backup-calls conj {:repo repo + :ext ext + :content (.toString content) + :opts opts}) + nil)] + (p/let [_ (electron-db/backup-db-with-sqlite-backup! + db-name + {:force-backup? true + :sqlite-backup! (fn [src dst] + (swap! custom-calls conj [src dst]) + (fs/writeFileSync dst "worker-copy") + (p/resolved nil))})] + (is (= [[db-path (second (first @custom-calls))]] + @custom-calls)) + (is (empty? @fallback-calls)) + (is (= 1 (count @backup-calls))) + (let [{:keys [repo ext content opts]} (first @backup-calls)] + (is (= db-name repo)) + (is (= ".sqlite" ext)) + (is (= "worker-copy" content)) + (is (= backups-path (:backups-dir opts))) + (is (= true (:force-backup? opts)))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + (deftest auto-backup-tracker-runs-hourly-for-active-repos (async done (let [set-interval-calls (atom []) @@ -128,9 +170,9 @@ timer-id)) (set! js/clearInterval (fn [id] (swap! clear-interval-calls conj id))) - (-> (p/with-redefs [electron-db/backup-db! (fn [repo _] - (swap! backup-calls conj repo) - (p/resolved nil))] + (-> (p/with-redefs [electron-db/backup-db-via-worker! (fn [repo window-id _] + (swap! backup-calls conj [repo window-id]) + (p/resolved nil))] (p/let [_ (electron-db/sync-auto-backup-repo! 1 "logseq_db_demo") _ (electron-db/sync-auto-backup-repo! 2 "logseq_db_demo") _ ((ffirst @set-interval-calls)) @@ -139,7 +181,7 @@ (is (= 1 (count @set-interval-calls))) (is (= [timer-id] @clear-interval-calls)) (is (= [3600000] (mapv second @set-interval-calls))) - (is (= ["logseq_db_demo"] @backup-calls)))) + (is (= [["logseq_db_demo" 1]] @backup-calls)))) (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally (fn [] diff --git a/src/test/logseq/db/sqlite/backup_test.cljs b/src/test/logseq/db/sqlite/backup_test.cljs new file mode 100644 index 0000000000..4fb9026e87 --- /dev/null +++ b/src/test/logseq/db/sqlite/backup_test.cljs @@ -0,0 +1,46 @@ +(ns logseq.db.sqlite.backup-test + (:require [cljs.test :refer [async deftest is]] + [frontend.test.node-helper :as node-helper] + [goog.object :as gobj] + [logseq.db.sqlite.backup :as sqlite-backup] + [promesa.core :as p] + ["fs-extra" :as fs] + ["path" :as node-path])) + +(deftest backup-connection-removes-partial-destination-on-failure + (async done + (let [dir (node-helper/create-tmp-dir "sqlite-backup-failure") + dst-path (node-path/join dir "backup.sqlite") + expected-error (js/Error. "backup failed")] + (-> (p/with-redefs [sqlite-backup/sqlite-backup-fn + (fn [_db path] + (fs/writeFileSync path "") + (p/rejected expected-error))] + (sqlite-backup/backup-connection! #js {} dst-path)) + (p/then (fn [_] + (is false "backup should reject"))) + (p/catch (fn [error] + (is (= expected-error error)) + (is (not (fs/existsSync dst-path))))) + (p/finally done))))) + +(deftest backup-db-file-with-existing-connection-propagates-backup-failure + (async done + (let [dir (node-helper/create-tmp-dir "sqlite-backup-file-failure") + dst-path (node-path/join dir "backup.sqlite") + expected-error (js/Error. "backup failed") + closed? (atom false) + db #js {}] + (gobj/set db "close" (fn [] (reset! closed? true))) + (-> (p/with-redefs [sqlite-backup/sqlite-backup-fn + (fn [_db path] + (fs/writeFileSync path "") + (p/rejected expected-error))] + (sqlite-backup/backup-db-file! db "source.sqlite" dst-path)) + (p/then (fn [_] + (is false "backup should reject"))) + (p/catch (fn [error] + (is (= expected-error error)) + (is (not @closed?)) + (is (not (fs/existsSync dst-path))))) + (p/finally done)))))