fix: sqlite db backup on desktop

This commit is contained in:
Tienson Qin
2026-05-03 12:04:22 +08:00
parent e470dfd5f0
commit 627cb619e1
5 changed files with 157 additions and 32 deletions

View File

@@ -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)))

View File

@@ -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!))

View File

@@ -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)]

View File

@@ -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 []

View File

@@ -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)))))