Merge pull request #12466 from logseq/refactor/sync-undo-semantic-ops

refactor: use semantic ops for both sync and undo/redo
This commit is contained in:
Tienson Qin
2026-03-30 17:26:58 +08:00
committed by GitHub
92 changed files with 10211 additions and 3693 deletions

View File

@@ -23,3 +23,5 @@ logseq.db-sync.snapshot/finalize-datoms-jsonl-buffer
logseq.db-sync.worker/worker
;; debugging
logseq.db-sync.worker.timing/summary
;; API
logseq.db-sync.checksum/recompute-checksum-diagnostics

View File

@@ -59,6 +59,18 @@ before it calls the worker delete endpoint for each graph. Set
`DB_SYNC_BASE_URL` and `DB_SYNC_ADMIN_TOKEN` or pass `--base-url` and
`--admin-token` when running it.
Delete a user completely (owned graphs, memberships, keys, and user row):
```bash
cd deps/db-sync
yarn delete-user-totally --username alice
yarn delete-user-totally --user-id us-east-1:example-user-id
```
The script prints all linked graphs first, deletes owned graphs through the
admin graph delete endpoint, then removes the user's remaining D1 references.
It requires typing `DELETE` as confirmation.
### Node.js Adapter (self-hosted)
Build the adapter:

View File

@@ -7,6 +7,7 @@
"watch": "clojure -M:cljs watch db-sync",
"release": "clojure -M:cljs release db-sync",
"delete-graphs-for-user": "node worker/scripts/delete_graphs_for_user.js",
"delete-user-totally": "node worker/scripts/delete_user_totally.js",
"show-graphs-for-user": "node worker/scripts/show_graphs_for_user.js",
"build:node-adapter": "clojure -M:cljs release db-sync-node",
"dev:node-adapter": "clojure -M:cljs watch db-sync-node",

View File

@@ -55,6 +55,12 @@
(or (parse-hex32 (subs checksum 8 16)) 0)]
[0 0]))
(defn- valid-checksum?
[checksum]
(boolean
(and (string? checksum)
(re-matches #"[0-9a-fA-F]{16}" checksum))))
(defn- state->checksum
[[fnv djb]]
(str (unsigned-hex fnv)
@@ -98,9 +104,9 @@
(not e2ee?) (hash-code field-separator)
(not e2ee?) (digest-string name)
(not e2ee?) (hash-code field-separator)
true (digest-string (some-> parent str))
true (digest-string (some-> parent :block/uuid str))
true (hash-code field-separator)
true (digest-string (some-> page str))))))
true (digest-string (some-> page :block/uuid str))))))
(defn recompute-checksum
[db]
@@ -120,25 +126,60 @@
[0 0])
state->checksum)))
(defn recompute-checksum-diagnostics
[db]
(let [e2ee? (boolean (ldb/get-graph-rtc-e2ee? db))
attrs (relevant-attrs e2ee?)
eids (->> (d/datoms db :eavt)
(keep (fn [datom]
(when (contains? attrs (:a datom))
(:e datom))))
distinct)
blocks (->> eids
(keep (fn [eid]
(let [{:keys [block/uuid block/title block/name block/parent block/page]} (entity-values db eid e2ee?)]
(when uuid
(cond-> {:block/uuid uuid
:block/parent parent
:block/page page}
(not e2ee?) (assoc :block/title title
:block/name name))))))
(sort-by (comp str :block/uuid))
vec)]
{:checksum (recompute-checksum db)
:e2ee? e2ee?
:attrs (->> attrs (sort-by str) vec)
:blocks blocks}))
(defn update-checksum
[checksum {:keys [db-before db-after tx-data]}]
(let [db (or db-after db-before)
e2ee? (ldb/get-graph-rtc-e2ee? db)
changed-eids (->> tx-data (keep :e) distinct)
initial-state (if (string? checksum)
(checksum->state checksum)
(checksum->state (when db-before (recompute-checksum db-before))))]
(->> changed-eids
(reduce (fn [[sum-fnv sum-djb] eid]
(let [old-digest (when db-before (entity-digest db-before eid e2ee?))
new-digest (when db-after (entity-digest db-after eid e2ee?))
[sum-fnv sum-djb] (if old-digest
[(sub-step sum-fnv (first old-digest))
(sub-step sum-djb (second old-digest))]
[sum-fnv sum-djb])]
(if new-digest
[(add-step sum-fnv (first new-digest))
(add-step sum-djb (second new-digest))]
[sum-fnv sum-djb])))
initial-state)
state->checksum)))
(let [before-e2ee? (ldb/get-graph-rtc-e2ee? db-before)
after-e2ee? (ldb/get-graph-rtc-e2ee? db-after)]
(if (not= before-e2ee? after-e2ee?)
;; E2EE mode changes the global digest semantics, so incremental deltas are invalid.
(recompute-checksum db-after)
(let [changed-eids (->> tx-data
(remove (fn [d]
(contains? #{:block/tx-id} (:a d))))
(keep (fn [d]
(let [e (:e d)]
(or (:block/uuid (d/entity db-before e))
(:block/uuid (d/entity db-after e))))))
distinct)
initial-state (if (valid-checksum? checksum)
(checksum->state checksum)
(checksum->state (recompute-checksum db-before)))]
(->> changed-eids
(reduce (fn [[sum-fnv sum-djb] uuid]
(let [old-digest (when-let [eid (:db/id (d/entity db-before [:block/uuid uuid]))]
(entity-digest db-before eid after-e2ee?))
new-digest (when-let [eid (:db/id (d/entity db-after [:block/uuid uuid]))]
(entity-digest db-after eid after-e2ee?))]
[(cond-> sum-fnv
old-digest (sub-step (first old-digest))
new-digest (add-step (first new-digest)))
(cond-> sum-djb
old-digest (sub-step (second old-digest))
new-digest (add-step (second new-digest)))]))
initial-state)
state->checksum)))))

View File

@@ -138,7 +138,8 @@
(def graphs-list-response-schema
[:map
[:graphs [:sequential graph-info-schema]]])
[:graphs [:sequential graph-info-schema]]
[:user-rsa-keys-exists? :boolean]])
(def graph-create-request-schema
[:map

View File

@@ -144,13 +144,11 @@
(restore-data-from-addr sql addr))))
(defn- append-tx-for-tx-report
[sql {:keys [db-after db-before tx-data tx-meta]}]
[sql {:keys [db-after db-before tx-data tx-meta] :as tx-report}]
(let [new-t (next-t! sql)
created-at (common/now-ms)
checksum (sync-checksum/update-checksum (get-checksum sql)
{:db-before db-before
:db-after db-after
:tx-data tx-data})
prev-checksum (get-checksum sql)
checksum (sync-checksum/update-checksum prev-checksum tx-report)
normalized-data (->> tx-data
(db-normalize/normalize-tx-data db-after db-before))
;; _ (prn :debug :tx-data tx-data)

View File

@@ -47,8 +47,14 @@
(case (:handler route)
:graphs/list
(if (string? user-id)
(p/let [graphs (index/<index-list db user-id)]
(http/json-response :graphs/list {:graphs graphs}))
(p/let [graphs (index/<index-list db user-id)
user-rsa-key-pair (index/<user-rsa-key-pair db user-id)
user-rsa-keys-exists?
(and (string? (:public-key user-rsa-key-pair))
(string? (:encrypted-private-key user-rsa-key-pair)))]
(http/json-response :graphs/list
{:graphs graphs
:user-rsa-keys-exists? user-rsa-keys-exists?}))
(http/unauthorized))
:graphs/create
@@ -70,14 +76,20 @@
(p/let [{:keys [graph-name schema-version graph-e2ee? graph-ready-for-use?]} body
graph-e2ee? (if (nil? graph-e2ee?) true (true? graph-e2ee?))
graph-ready-for-use? (if (nil? graph-ready-for-use?) true (true? graph-ready-for-use?))
name-exists? (index/<graph-name-exists? db graph-name user-id)]
name-exists? (index/<graph-name-exists? db graph-name user-id)
user-rsa-key-pair (index/<user-rsa-key-pair db user-id)
has-user-rsa-key-pair?
(and (string? (:public-key user-rsa-key-pair))
(string? (:encrypted-private-key user-rsa-key-pair)))]
(if name-exists?
(http/bad-request "duplicate graph name")
(p/let [_ (index/<index-upsert! db graph-id graph-name user-id schema-version graph-e2ee? graph-ready-for-use?)
_ (index/<graph-member-upsert! db graph-id user-id "manager" user-id)]
(http/json-response :graphs/create {:graph-id graph-id
:graph-e2ee? graph-e2ee?
:graph-ready-for-use? graph-ready-for-use?})))))))))
(if-not has-user-rsa-key-pair?
(http/bad-request "missing user rsa key pair")
(p/let [_ (index/<index-upsert! db graph-id graph-name user-id schema-version graph-e2ee? graph-ready-for-use?)
_ (index/<graph-member-upsert! db graph-id user-id "manager" user-id)]
(http/json-response :graphs/create {:graph-id graph-id
:graph-e2ee? graph-e2ee?
:graph-ready-for-use? graph-ready-for-use?}))))))))))
:graphs/access
(cond

View File

@@ -13,7 +13,8 @@
[logseq.db-sync.worker.routes.sync :as sync-routes]
[logseq.db-sync.worker.ws :as ws]
[logseq.db.frontend.schema :as db-schema]
[promesa.core :as p]))
[promesa.core :as p]
[logseq.db-sync.checksum :as checksum]))
(def ^:private snapshot-download-batch-size 10000)
(def ^:private snapshot-cache-control "private, max-age=300")
@@ -55,9 +56,18 @@
(defn current-checksum [^js self]
(ensure-conn! self)
(let [db @(.-conn self)]
(when-not (ldb/get-graph-rtc-e2ee? db)
(storage/get-checksum (.-sql self)))))
(let [db @(.-conn self)
full-checksum (checksum/recompute-checksum db)
cur-checksum (storage/get-checksum (.-sql self))]
(if (or (nil? cur-checksum)
(= full-checksum cur-checksum))
cur-checksum
(do
(log/error :db-sync/server-checksum-mismatch
{:full-checksum full-checksum
:current-checksum cur-checksum})
(storage/set-checksum! (.-sql self) full-checksum)
full-checksum))))
(defn snapshot-upload-finished? [^js self]
(ensure-schema! self)

View File

@@ -4,40 +4,139 @@
[logseq.db-sync.checksum :as checksum]
[logseq.db.frontend.schema :as db-schema]))
(deftest checksum-ignores-unrelated-datoms-test
(testing "checksum only depends on uuid, title, parent, and page"
(let [page-uuid (random-uuid)
block-uuid (random-uuid)
base-db (-> (d/empty-db db-schema/schema)
(d/db-with [{:db/id 1
:block/uuid page-uuid
:block/name "page"
:block/title "Page"}
{:db/id 2
:block/uuid block-uuid
:block/title "Block"
:block/parent 1
:block/page 1}]))
db-with-unrelated (d/db-with base-db [[:db/add 2 :block/updated-at 1773661308002]
[:db/add 2 :logseq.property/created-by-ref 99]])]
(is (= (checksum/recompute-checksum base-db)
(checksum/recompute-checksum db-with-unrelated))))))
(defn- sample-db
[]
(let [page-a-uuid (random-uuid)
page-b-uuid (random-uuid)
parent-uuid (random-uuid)
child-uuid (random-uuid)]
(-> (d/empty-db db-schema/schema)
(d/db-with [{:db/id 1
:block/uuid page-a-uuid
:block/name "page-a"
:block/title "Page A"}
{:db/id 2
:block/uuid page-b-uuid
:block/name "page-b"
:block/title "Page B"}
{:db/id 3
:block/uuid parent-uuid
:block/title "Parent"
:block/parent 1
:block/page 1}
{:db/id 4
:block/uuid child-uuid
:block/title "Child"
:block/parent 3
:block/page 1}]))))
(deftest incremental-checksum-matches-recompute-test
(testing "incremental checksum matches full recompute after a tx"
(let [page-uuid (random-uuid)
block-uuid (random-uuid)
db-before (-> (d/empty-db db-schema/schema)
(d/db-with [{:db/id 1
:block/uuid page-uuid
:block/name "page"
:block/title "Page"}
{:db/id 2
:block/uuid block-uuid
:block/title "Block"
:block/parent 1
:block/page 1}]))
tx-report (d/with db-before [[:db/add 2 :block/title "Updated"]
[:db/add 1 :block/name "page-updated"]])]
(defn- assert-incremental=full!
[db-before checksum-before tx-data]
(let [tx-report (d/with db-before tx-data)
full (checksum/recompute-checksum (:db-after tx-report))
incremental (checksum/update-checksum checksum-before tx-report)]
(is (= full incremental)
(str "Expected checksum parity for tx-data: " (pr-str tx-data)))
{:db (:db-after tx-report)
:checksum incremental}))
(deftest checksum-ignores-unrelated-datoms-test
(testing "recompute and incremental checksums ignore unrelated datoms"
(let [db-before (sample-db)
checksum-before (checksum/recompute-checksum db-before)
tx-data [[:db/add 4 :block/updated-at 1773661308002]
[:db/add 4 :logseq.property/created-by-ref 99]]
tx-report (d/with db-before tx-data)]
(is (= checksum-before
(checksum/recompute-checksum (:db-after tx-report))))
(is (= checksum-before
(checksum/update-checksum checksum-before tx-report))))))
(deftest incremental-checksum-matches-recompute-on-replace-datom-test
(testing "incremental checksum matches full recompute when replacing existing values"
(let [db-before (sample-db)
tx-report (d/with db-before [[:db/add 4 :block/title "Child updated"]
[:db/add 1 :block/name "page-a-updated"]])]
(is (= (checksum/recompute-checksum (:db-after tx-report))
(checksum/update-checksum (checksum/recompute-checksum db-before) tx-report))))))
(deftest incremental-checksum-matches-recompute-across-mixed-mutations-test
(testing "incremental checksum stays equal to full recompute across typical tx sequences"
(let [db0 (sample-db)
new-block-uuid (random-uuid)
{:keys [db checksum]} (reduce
(fn [{:keys [db checksum]} {:keys [tx-data]}]
(assert-incremental=full! db checksum tx-data))
{:db db0
:checksum (checksum/recompute-checksum db0)}
[{:tx-data [[:db/add 4 :block/title "Child edited"]]}
{:tx-data [[:db/add 1 :block/name "page-a-renamed"]
[:db/add 1 :block/title "Page A Renamed"]]}
{:tx-data [[:db/add 4 :block/parent 2]
[:db/add 4 :block/page 2]]}
{:tx-data [[:db/add -1 :block/uuid new-block-uuid]
[:db/add -1 :block/title "New block"]
[:db/add -1 :block/parent 2]
[:db/add -1 :block/page 2]]}
{:tx-data [[:db/retract 3 :block/title "Parent"]]}
{:tx-data [[:db/retractEntity [:block/uuid new-block-uuid]]]}
{:tx-data [[:db/add 4 :block/updated-at 1773661308002]]}])]
(is (= checksum (checksum/recompute-checksum db))))))
(deftest incremental-checksum-uses-recompute-when-initial-checksum-missing-test
(testing "nil initial checksum uses db-before recompute as baseline"
(let [db-before (sample-db)
tx-report (d/with db-before [[:db/add 4 :block/title "Child updated"]])]
(is (= (checksum/recompute-checksum (:db-after tx-report))
(checksum/update-checksum nil tx-report))))))
(deftest checksum-e2ee-ignores-title-and-name-test
(testing "with E2EE enabled, checksum ignores title/name changes for both modes"
(let [db-before (-> (sample-db)
(d/db-with [{:db/ident :logseq.kv/graph-rtc-e2ee?
:kv/value true}]))
checksum-before (checksum/recompute-checksum db-before)
tx-report (d/with db-before [[:db/add 4 :block/title "Encrypted title update"]
[:db/add 1 :block/name "encrypted-name-update"]])]
(is (= checksum-before
(checksum/recompute-checksum (:db-after tx-report))))
(is (= checksum-before
(checksum/update-checksum checksum-before tx-report))))))
(deftest incremental-checksum-recomputes-when-e2ee-mode-toggles-test
(testing "incremental checksum falls back to full recompute when E2EE mode changes"
(let [db-before (sample-db)
tx-report (d/with db-before [{:db/ident :logseq.kv/graph-rtc-e2ee?
:kv/value true}])]
(is (= (checksum/recompute-checksum (:db-after tx-report))
(checksum/update-checksum (checksum/recompute-checksum db-before) tx-report))))))
(deftest recompute-checksum-diagnostics-includes-relevant-attrs-test
(testing "diagnostics includes checksum attrs and block values used for checksum export"
(let [db (sample-db)
{:keys [checksum attrs blocks e2ee?]} (checksum/recompute-checksum-diagnostics db)
child-uuid (:block/uuid (d/entity db 4))
child-parent-uuid (:block/uuid (:block/parent (d/entity db 4)))
child-page-uuid (:block/uuid (:block/page (d/entity db 4)))
child (some #(when (= child-uuid (:block/uuid %)) %) blocks)]
(is (false? e2ee?))
(is (= (checksum/recompute-checksum db) checksum))
(is (= #{:block/uuid :block/title :block/name :block/parent :block/page}
(set attrs)))
(is (= 4 (count blocks)))
(is (= child-parent-uuid (:block/parent child)))
(is (= child-page-uuid (:block/page child)))
(is (string? (:block/title child))))))
(deftest recompute-checksum-diagnostics-omits-title-and-name-in-e2ee-test
(testing "diagnostics for E2EE graphs omits title/name from checksum attrs and export blocks"
(let [db (-> (sample-db)
(d/db-with [{:db/ident :logseq.kv/graph-rtc-e2ee?
:kv/value true}]))
{:keys [checksum attrs blocks e2ee?]} (checksum/recompute-checksum-diagnostics db)]
(is e2ee?)
(is (= (checksum/recompute-checksum db) checksum))
(is (= #{:block/uuid :block/parent :block/page}
(set attrs)))
(is (every? #(not (contains? % :block/title)) blocks))
(is (every? #(not (contains? % :block/name)) blocks)))))

View File

@@ -62,6 +62,9 @@
(-> (p/with-redefs [common/read-json (fn [_]
(p/resolved #js {"graph-name" "Graph 1"
"schema-version" "65"}))
index/<user-rsa-key-pair (fn [_db _user-id]
(p/resolved {:public-key "pk"
:encrypted-private-key "enc"}))
common/<d1-all (fn [& _]
(p/resolved #js {:results #js []}))
common/get-sql-rows (fn [result]
@@ -89,3 +92,136 @@
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest graphs-list-includes-user-rsa-keys-exists-flag-true-test
(async done
(let [request (js/Request. "http://localhost/graphs" #js {:method "GET"})
url (js/URL. (.-url request))]
(-> (p/with-redefs [index/<index-list (fn [_db _user-id]
(p/resolved []))
index/<user-rsa-key-pair (fn [_db _user-id]
(p/resolved {:public-key "pk"
:encrypted-private-key "enc"}))]
(p/let [resp (index-handler/handle {:db :db
:env #js {}
:request request
:url url
:claims #js {"sub" "user-1"}
:route {:handler :graphs/list
:path-params {}}})
text (.text resp)
body (js->clj (js/JSON.parse text) :keywordize-keys true)]
(is (= 200 (.-status resp)))
(is (= [] (:graphs body)))
(is (= true (:user-rsa-keys-exists? body)))))
(p/then (fn []
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest graphs-list-includes-user-rsa-keys-exists-flag-false-test
(async done
(let [request (js/Request. "http://localhost/graphs" #js {:method "GET"})
url (js/URL. (.-url request))]
(-> (p/with-redefs [index/<index-list (fn [_db _user-id]
(p/resolved []))
index/<user-rsa-key-pair (fn [_db _user-id]
(p/resolved nil))]
(p/let [resp (index-handler/handle {:db :db
:env #js {}
:request request
:url url
:claims #js {"sub" "user-1"}
:route {:handler :graphs/list
:path-params {}}})
text (.text resp)
body (js->clj (js/JSON.parse text) :keywordize-keys true)]
(is (= 200 (.-status resp)))
(is (= [] (:graphs body)))
(is (= false (:user-rsa-keys-exists? body)))))
(p/then (fn []
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest graphs-create-e2ee-requires-user-rsa-key-pair-test
(async done
(let [request (js/Request. "http://localhost/graphs" #js {:method "POST"})
url (js/URL. (.-url request))
index-upsert-calls* (atom 0)]
(-> (p/with-redefs [common/read-json (fn [_]
(p/resolved #js {"graph-name" "Graph E2EE"
"schema-version" "65"
"graph-e2ee?" true}))
index/<graph-name-exists? (fn [_db _graph-name _user-id]
(p/resolved false))
index/<user-rsa-key-pair (fn [_db _user-id]
(p/resolved nil))
index/<index-upsert! (fn
([_db _graph-id _graph-name _user-id _schema-version _graph-e2ee?]
(swap! index-upsert-calls* inc)
(p/resolved nil))
([_db _graph-id _graph-name _user-id _schema-version _graph-e2ee? _graph-ready-for-use?]
(swap! index-upsert-calls* inc)
(p/resolved nil)))
index/<graph-member-upsert! (fn [& _]
(p/resolved nil))]
(p/let [resp (index-handler/handle {:db :db
:env #js {}
:request request
:url url
:claims #js {"sub" "user-1"}
:route {:handler :graphs/create
:path-params {}}})
text (.text resp)
body (js->clj (js/JSON.parse text) :keywordize-keys true)]
(is (= 400 (.-status resp)))
(is (= "missing user rsa key pair" (:error body)))
(is (zero? @index-upsert-calls*))))
(p/then (fn []
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest graphs-create-non-e2ee-requires-user-rsa-key-pair-test
(async done
(let [request (js/Request. "http://localhost/graphs" #js {:method "POST"})
url (js/URL. (.-url request))
index-upsert-calls* (atom 0)]
(-> (p/with-redefs [common/read-json (fn [_]
(p/resolved #js {"graph-name" "Graph Plain"
"schema-version" "65"
"graph-e2ee?" false}))
index/<graph-name-exists? (fn [_db _graph-name _user-id]
(p/resolved false))
index/<user-rsa-key-pair (fn [_db _user-id]
(p/resolved nil))
index/<index-upsert! (fn
([_db _graph-id _graph-name _user-id _schema-version _graph-e2ee?]
(swap! index-upsert-calls* inc)
(p/resolved nil))
([_db _graph-id _graph-name _user-id _schema-version _graph-e2ee? _graph-ready-for-use?]
(swap! index-upsert-calls* inc)
(p/resolved nil)))
index/<graph-member-upsert! (fn [& _]
(p/resolved nil))]
(p/let [resp (index-handler/handle {:db :db
:env #js {}
:request request
:url url
:claims #js {"sub" "user-1"}
:route {:handler :graphs/create
:path-params {}}})
text (.text resp)
body (js->clj (js/JSON.parse text) :keywordize-keys true)]
(is (= 400 (.-status resp)))
(is (= "missing user rsa key pair" (:error body)))
(is (zero? @index-upsert-calls*))))
(p/then (fn []
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))

View File

@@ -226,6 +226,25 @@
(is false (str error))
(done)))))))
(deftest current-checksum-heals-stale-stored-checksum-test
(testing "server recomputes and persists checksum when stored checksum is stale"
(let [sql (test-sql/make-sql)
conn (storage/open-conn sql)
self #js {:sql sql
:conn conn
:schema-ready true}
stale-checksum "0000000000000000"
block-uuid (random-uuid)]
(d/transact! conn [{:block/uuid block-uuid
:block/title "hello"}])
(is (string? (storage/get-checksum sql)))
(storage/set-checksum! sql stale-checksum)
(let [healed (sync-handler/current-checksum self)]
(is (string? healed))
(is (not= stale-checksum healed))
(is (= healed (storage/get-checksum sql)))
(is (= healed (sync-handler/current-checksum self)))))))
(deftest tx-batch-rejects-with-the-exact-failed-tx-entry-test
(testing "db transact failure replies with the specific rejected tx entry"
(let [sql (test-sql/make-sql)

View File

@@ -16,6 +16,33 @@ const {
runWranglerQuery,
} = require("./graph_user_lib");
function escapeSqlValue(value) {
return value.replaceAll("'", "''");
}
function ensureMutationSuccess(output, context) {
if (!Array.isArray(output) || output.length === 0) {
fail(`Unexpected empty response from wrangler while ${context}.`);
}
output.forEach((statement, index) => {
if (!statement.success) {
fail(`Wrangler mutation failed while ${context} (statement ${index + 1}).`);
}
});
}
function deleteGraphAesKeys(options, graphId) {
const sql = `delete from graph_aes_keys where graph_id = '${escapeSqlValue(graphId)}'`;
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
ensureMutationSuccess(runWranglerQuery(wranglerArgs), `deleting graph_aes_keys for ${graphId}`);
}
function printHelp() {
console.log(`Delete db-sync graphs owned by a user from a remote D1 environment.
@@ -134,6 +161,8 @@ async function main() {
const body = await response.text();
fail(`Delete failed for ${graph.graph_id}: ${response.status} ${body}`);
}
deleteGraphAesKeys(options, graph.graph_id);
}
console.log(`Deleted ${result.graphs.length} owned graph(s).`);

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
const path = require("node:path");
const readline = require("node:readline/promises");
const { stdin, stdout } = require("node:process");
const { parseArgs } = require("node:util");
const {
buildAdminGraphDeleteUrl,
buildUserGraphsSql,
buildWranglerArgs,
defaultConfigPath,
fail,
formatUserGraphsResult,
parseWranglerResults,
printUserGraphsTable,
runWranglerQuery,
} = require("./graph_user_lib");
function printHelp() {
console.log(`Delete a db-sync user and all related data from a remote D1 environment.
Usage:
node worker/scripts/delete_user_totally.js --username <username> [--env prod]
node worker/scripts/delete_user_totally.js --user-id <user-id> [--env prod]
Options:
--username <username> Look up the target user by username.
--user-id <user-id> Look up the target user by user id.
--env <env> Wrangler environment to use. Defaults to "prod".
--database <name> D1 binding or database name. Defaults to "DB".
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
--admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
--help Show this message.
`);
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
username: { type: "string" },
"user-id": { type: "string" },
env: { type: "string", default: "prod" },
database: { type: "string", default: "DB" },
config: { type: "string", default: defaultConfigPath },
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
help: { type: "boolean", default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
if (lookupCount !== 1) {
fail("Pass exactly one of --username or --user-id.");
}
return {
lookupField: values.username ? "username" : "id",
lookupLabel: values.username ? "username" : "user-id",
lookupValue: values.username ?? values["user-id"],
env: values.env,
database: values.database,
config: path.resolve(values.config),
baseUrl: values["base-url"],
adminToken: values["admin-token"],
};
}
function escapeSqlValue(value) {
return value.replaceAll("'", "''");
}
function runSelectQuery(options, sql) {
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
return parseWranglerResults(runWranglerQuery(wranglerArgs));
}
function runMutationQuery(options, sql) {
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
const output = runWranglerQuery(wranglerArgs);
if (!Array.isArray(output) || output.length === 0) {
throw new Error("Unexpected empty response from wrangler.");
}
output.forEach((statement, index) => {
if (!statement.success) {
throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`);
}
});
return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0);
}
function sqlCountToNumber(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : 0;
}
function isDeleteConfirmationAccepted(answer, userId) {
const normalizedAnswer = answer.trim();
return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`;
}
async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
const answer = await rl.question(
`Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `,
);
return isDeleteConfirmationAccepted(answer, user.user_id);
} finally {
rl.close();
}
}
async function deleteOwnedGraphs(options, ownedGraphs) {
for (const graph of ownedGraphs) {
const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
method: "DELETE",
headers: {
"x-db-sync-admin-token": options.adminToken,
},
});
if (!response.ok) {
const payload = await response.text();
fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`);
}
}
}
async function main() {
const options = parseCliArgs(process.argv.slice(2));
const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false }));
const result = formatUserGraphsResult(graphRows);
if (!result) {
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
}
const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner");
const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner");
printUserGraphsTable(result, "Graphs linked to user");
console.log(`Owned graphs: ${ownedGraphs.length}`);
console.log(`Member graphs: ${memberGraphs.length}`);
if (ownedGraphs.length > 0 && !options.baseUrl) {
fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
}
if (ownedGraphs.length > 0 && !options.adminToken) {
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
}
const confirmed = await confirmDeletion({
user: result.user,
ownedGraphsCount: ownedGraphs.length,
memberGraphsCount: memberGraphs.length,
});
if (!confirmed) {
console.log("Aborted.");
return;
}
if (ownedGraphs.length > 0) {
await deleteOwnedGraphs(options, ownedGraphs);
}
const escapedUserId = escapeSqlValue(result.user.user_id);
const remainingOwnedGraphRows = runSelectQuery(
options,
`select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`,
);
const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count);
if (remainingOwnedGraphCount > 0) {
fail(
`Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`,
);
}
const deletedGraphAesKeys = runMutationQuery(
options,
`delete from graph_aes_keys where user_id = '${escapedUserId}'`,
);
const deletedGraphMembers = runMutationQuery(
options,
`delete from graph_members where user_id = '${escapedUserId}'`,
);
const clearedInvitedBy = runMutationQuery(
options,
`update graph_members set invited_by = null where invited_by = '${escapedUserId}'`,
);
const deletedUserRsaKeys = runMutationQuery(
options,
`delete from user_rsa_keys where user_id = '${escapedUserId}'`,
);
const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`);
if (deletedUsers !== 1) {
fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`);
}
const userRowsAfterDelete = runSelectQuery(
options,
`select id from users where id = '${escapedUserId}' limit 1`,
);
if (userRowsAfterDelete.length > 0) {
fail(`User ${result.user.user_id} still exists after deletion.`);
}
console.table([
{ step: "owned graphs deleted", rows: ownedGraphs.length },
{ step: "graph_aes_keys deleted", rows: deletedGraphAesKeys },
{ step: "graph_members deleted", rows: deletedGraphMembers },
{ step: "graph_members invited_by cleared", rows: clearedInvitedBy },
{ step: "user_rsa_keys deleted", rows: deletedUserRsaKeys },
{ step: "users deleted", rows: deletedUsers },
]);
console.log(`Deleted user ${result.user.user_id} successfully.`);
}
if (require.main === module) {
main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
}
module.exports = {
confirmDeletion,
isDeleteConfirmationAccepted,
parseCliArgs,
};

View File

@@ -0,0 +1,59 @@
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const test = require("node:test");
const { isDeleteConfirmationAccepted, parseCliArgs } = require("./delete_user_totally");
const { defaultConfigPath } = require("./graph_user_lib");
function runCli(args) {
return spawnSync(process.execPath, [path.join(__dirname, "delete_user_totally.js"), ...args], {
encoding: "utf8",
});
}
test("parseCliArgs accepts --username", () => {
const parsed = parseCliArgs(["--username", "alice"]);
assert.equal(parsed.lookupField, "username");
assert.equal(parsed.lookupLabel, "username");
assert.equal(parsed.lookupValue, "alice");
assert.equal(parsed.env, "prod");
assert.equal(parsed.database, "DB");
assert.equal(parsed.config, path.resolve(defaultConfigPath));
});
test("parseCliArgs accepts --user-id", () => {
const parsed = parseCliArgs(["--user-id", "user-123"]);
assert.equal(parsed.lookupField, "id");
assert.equal(parsed.lookupLabel, "user-id");
assert.equal(parsed.lookupValue, "user-123");
});
test("CLI --help exits successfully", () => {
const result = runCli(["--help"]);
assert.equal(result.status, 0);
assert.match(result.stdout, /Delete a db-sync user and all related data/);
});
test("CLI rejects passing both --username and --user-id", () => {
const result = runCli(["--username", "alice", "--user-id", "user-123"]);
assert.equal(result.status, 1);
assert.match(result.stderr, /Pass exactly one of --username or --user-id\./);
});
test("confirmation accepts DELETE", () => {
assert.equal(isDeleteConfirmationAccepted("DELETE", "user-123"), true);
});
test("confirmation accepts legacy DELETE USER <id>", () => {
assert.equal(isDeleteConfirmationAccepted("DELETE USER user-123", "user-123"), true);
});
test("confirmation rejects unrelated input", () => {
assert.equal(isDeleteConfirmationAccepted("DELETE USER other-user", "user-123"), false);
assert.equal(isDeleteConfirmationAccepted("yes", "user-123"), false);
});

View File

@@ -5,6 +5,7 @@
[clojure.walk :as walk]
[datascript.conn :as dc]
[datascript.core :as d]
[datascript.storage :as storage]
[datascript.impl.entity :as de]
[logseq.common.config :as common-config]
[logseq.common.plural :as common-plural]
@@ -13,13 +14,13 @@
[logseq.db.common.delete-blocks :as delete-blocks] ;; Load entity extensions
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.initial-data :as common-initial-data]
[logseq.db.common.normalize :as db-normalize]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.db :as db-db]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.property :as db-property]
[logseq.db.frontend.validate :as db-validate]
[logseq.db.sqlite.util :as sqlite-util])
[logseq.db.sqlite.util :as sqlite-util]
[logseq.common.log :as log])
(:refer-clojure :exclude [object?]))
(def built-in? entity-util/built-in?)
@@ -33,6 +34,7 @@
(defonce *transact-fn (atom nil))
(defonce *transact-invalid-callback (atom nil))
(defonce *transact-pipeline-fn (atom nil))
(defonce *debounce-fn (atom nil))
(defn register-transact-fn!
[f]
@@ -43,11 +45,15 @@
(defn register-transact-pipeline-fn!
[f]
(when f (reset! *transact-pipeline-fn f)))
(defn register-debounce-fn!
[f]
(when f (reset! *debounce-fn f)))
(defn- remove-temp-block-data
[tx-data]
(let [remove-block-temp-f (fn [m]
(->> (remove (fn [[k _v]] (= "block.temp" (namespace k))) m)
(->> (remove (fn [[k _v]]
(= "block.temp" (namespace k))) m)
(into {})))]
(keep (fn [data]
(cond
@@ -81,15 +87,6 @@
f))
tx-data))
(comment
(defn- skip-db-validate?
[datoms]
(every?
(fn [d]
(contains? #{:logseq.property/created-by-ref :block/refs :block/tx-id}
(:a d)))
datoms)))
(defn- throw-if-page-has-block-parent!
[db tx-data]
(when (some (fn [d] (and (:added d)
@@ -99,6 +96,12 @@
(throw (ex-info "Page can't have block as parent"
{:tx-data tx-data}))))
(defn debounced-store-db
[conn]
(when-some [_storage (storage/storage @conn)]
(let [f (or @*debounce-fn d/store)]
(f @conn))))
(defn- transact-sync
[conn tx-data tx-meta]
(try
@@ -106,10 +109,10 @@
db-based? (entity-plus/db-based-graph? db)]
(if (and db-based?
(not
(or (:batch-temp-conn? @conn)
(:rtc-download-graph? tx-meta)
(or (:rtc-download-graph? tx-meta)
(:reset-conn! tx-meta)
(:initial-db? tx-meta)
(:skip-validate-db? db)
(:skip-validate-db? tx-meta false)
(:logseq.graph-parser.exporter/new-graph? tx-meta))))
(let [tx-report* (d/with db tx-data tx-meta)
@@ -119,11 +122,15 @@
[validate-result errors] (db-validate/validate-tx-report tx-report nil)]
(cond
validate-result
(when (and tx-report (seq (:tx-data tx-report)))
(when (and tx-report
(seq (:tx-data tx-report)))
;; perf enhancement: avoid repeated call on `d/with`
(reset! conn (:db-after tx-report))
(dc/store-after-transact! conn tx-report)
(dc/run-callbacks conn tx-report))
(if (:batch-tx? @conn)
(dc/run-callbacks conn tx-report)
(do
(debounced-store-db conn)
(dc/run-callbacks conn tx-report))))
:else
(do
@@ -182,33 +189,70 @@
(transact-fn repo-or-conn tx-data tx-meta)
(transact-sync repo-or-conn tx-data tx-meta))))))
(defn transact-with-temp-conn!
(defn batch-transact-with-temp-conn!
"Validate db and store once for a batch transaction, the `temp` conn can still load data from disk,
however it can't write to the disk."
however it can't write to the disk.
This fn supports nested calls, however, don't rely on the tx-report for undo/redo."
[conn tx-meta batch-tx-fn & {:keys [listen-db]}]
(let [temp-conn (d/conn-from-db @conn)
*batch-tx-data (volatile! [])]
*batch-tx-data (volatile! [])
*complete? (volatile! false)]
;; can read from disk, write is disallowed
(swap! temp-conn assoc
:skip-store? true
:batch-temp-conn? true)
:batch-tx? true
:skip-validate-db? true)
(d/listen! temp-conn ::temp-conn-batch-tx
(fn [{:keys [tx-data] :as tx-report}]
(vswap! *batch-tx-data into tx-data)
(when (fn? listen-db)
(listen-db tx-report))))
(batch-tx-fn temp-conn *batch-tx-data)
(let [tx-data @*batch-tx-data
temp-after-db @temp-conn]
(d/unlisten! temp-conn ::temp-conn-batch-tx)
(reset! temp-conn nil)
(vreset! *batch-tx-data nil)
(when (seq tx-data)
;; transact tx-data to `conn` and validate db
(let [tx-data' (->>
tx-data
(db-normalize/replace-attr-retract-with-retract-entity temp-after-db))]
(transact! conn tx-data' tx-meta))))))
(try
(batch-tx-fn temp-conn *batch-tx-data)
(vreset! *complete? true)
(finally
(let [tx-data @*batch-tx-data]
(d/unlisten! temp-conn ::temp-conn-batch-tx)
(reset! temp-conn nil)
(vreset! *batch-tx-data nil)
(when (and @*complete? (seq tx-data))
;; transact tx-data to `conn` and validate db
(transact! conn tx-data tx-meta)))))))
(defn batch-transact!
"Store once for a batch transaction, notice that this fn doesn't support nest `batch-transact` calls"
[conn tx-meta batch-tx-fn & {:keys [listen-db]}]
(let [db-before @conn
*tx-data (atom [])]
(try
(when (:batch-tx @conn)
(throw (ex-info "batch-transact! can't be nested called" {:tx-meta tx-meta})))
(when (fn? listen-db) (d/listen! conn ::batch-tx
(fn [tx-report]
(swap! *tx-data into (:tx-data tx-report))
(listen-db tx-report))))
(swap! conn assoc :skip-store? true :batch-tx? true)
(batch-tx-fn conn)
(when (fn? listen-db) (d/unlisten! conn ::batch-tx))
(swap! conn dissoc :skip-store? :batch-tx?)
(debounced-store-db conn)
(let [batch-tx-data @*tx-data
_ (reset! *tx-data nil)
tx-report {:db-before db-before
:db-after @conn
:tx-meta tx-meta
:tx-data batch-tx-data}]
(dc/run-callbacks conn tx-report)
tx-report)
(catch :default e
(log/error e)
(reset! conn db-before)
(swap! conn dissoc :skip-store? :batch-tx?)
(reset! *tx-data nil)
(throw e)))))
(def page? entity-util/page?)
(def internal-page? entity-util/internal-page?)
@@ -280,23 +324,73 @@
:else
(:block/_parent parent)))))
(defn- get-right-sibling-for-property-children
[block parent]
(assert (or (de/entity? block) (nil? block)))
(let [children (get-block-children-or-property-children block parent)
right (some (fn [child] (when (> (compare (:block/order child) (:block/order block)) 0) child)) children)]
(when (not= (:db/id right) (:db/id block))
right)))
(defn get-right-sibling
[block]
(assert (or (de/entity? block) (nil? block)))
(when-let [parent (:block/parent block)]
(let [children (get-block-children-or-property-children block parent)
right (some (fn [child] (when (> (compare (:block/order child) (:block/order block)) 0) child)) children)]
(when (not= (:db/id right) (:db/id block))
right))))
(cond
(:block/closed-value-property block)
(get-right-sibling-for-property-children block parent)
(:logseq.property/created-from-property block)
(get-right-sibling-for-property-children block parent)
:else
(let [db (.-db block)
datoms (d/datoms db :avet :block/parent (:db/id parent))
child-orders (->> (map (fn [d]
[(:e d)
(:v (first (d/datoms db :eavt (:e d) :block/order)))]) datoms)
(sort-by last))
block-order (:block/order block)]
(some (fn [[e child-order]]
(when (and (> (compare child-order block-order) 0)
(not (seq (d/datoms db :avet :logseq.property/created-from-property e)))
(not (seq (d/datoms db :avet :block/closed-value-property e))))
(d/entity db e))) child-orders)))))
(defn- get-left-sibling-for-property-children
[block parent]
(assert (or (de/entity? block) (nil? block)))
(let [children (reverse (get-block-children-or-property-children block parent))
left (some (fn [child] (when (< (compare (:block/order child) (:block/order block)) 0) child)) children)]
(when (not= (:db/id left) (:db/id block))
left)))
(defn get-left-sibling
[block]
(assert (or (de/entity? block) (nil? block)))
(when-let [parent (:block/parent block)]
(let [children (reverse (get-block-children-or-property-children block parent))
left (some (fn [child] (when (< (compare (:block/order child) (:block/order block)) 0) child)) children)]
(when (not= (:db/id left) (:db/id block))
left))))
(cond
(:block/closed-value-property block)
(get-left-sibling-for-property-children block parent)
(:logseq.property/created-from-property block)
(get-left-sibling-for-property-children block parent)
:else
(let [db (.-db block)
datoms (d/datoms db :avet :block/parent (:db/id parent))
child-orders (->> (map (fn [d]
[(:e d)
(:v (first (d/datoms db :eavt (:e d) :block/order)))]) datoms)
(sort-by last)
reverse)
block-order (:block/order block)]
(some (fn [[e child-order]]
(when (and (< (compare child-order block-order) 0)
(not (seq (d/datoms db :avet :logseq.property/created-from-property e)))
(not (seq (d/datoms db :avet :block/closed-value-property e))))
(d/entity db e))) child-orders)))))
(defn get-down
[block]

View File

@@ -95,7 +95,7 @@
(d/datom 1 :property :v1 (+ tx 2) true)]))
(is (= :v1 (:property (d/entity @conn 1)))))))
(deftest test-transact-with-temp-conn!
(deftest test-batch-transact!
(testing "DB validation should be running after the whole transaction"
(let [conn (db-test/create-conn)]
(testing "#Task shouldn't be converted to property"
@@ -104,10 +104,10 @@
(db-test/silence-stderr
(ldb/transact! conn [{:db/ident :logseq.class/Task
:block/tags :logseq.class/Property}]))))))
(ldb/transact-with-temp-conn!
(ldb/batch-transact-with-temp-conn!
conn
{}
(fn [temp-conn _*batch-tx-data]
(fn [temp-conn]
(ldb/transact! temp-conn [{:db/ident :logseq.class/Task
:block/tags :logseq.class/Property}])
(ldb/transact! temp-conn [[:db/retract :logseq.class/Task :block/tags :logseq.class/Property]]))))))

View File

@@ -397,19 +397,19 @@
db-based?
sanitize-hashtag-name)
[page _page-entity] (cond
(and original-page-name (string? original-page-name))
(page-name-string->map original-page-name db date-formatter
(assoc options :with-timestamp? with-timestamp?))
:else
(let [page (cond (and (map? original-page-name) (:block/uuid original-page-name))
original-page-name
(and original-page-name (string? original-page-name))
(page-name-string->map original-page-name db date-formatter
(assoc options :with-timestamp? with-timestamp?))
:else
(let [page (cond (and (map? original-page-name) (:block/uuid original-page-name))
original-page-name
(map? original-page-name)
(assoc original-page-name :block/uuid (or page-uuid (d/squuid)))
(map? original-page-name)
(assoc original-page-name :block/uuid (or page-uuid (d/squuid)))
:else
nil)]
[page nil]))]
:else
nil)]
[page nil]))]
(when page
(if db-based?
(let [tags (if class? [:logseq.class/Tag]
@@ -880,4 +880,4 @@
[others parents' result'])))]
(recur blocks parents result))))
result' (map (fn [block] (assoc block :block/order (db-order/gen-key))) result)]
(concat result' other-blocks)))
(concat result' other-blocks)))

View File

@@ -6,5 +6,7 @@
logseq.outliner.core
logseq.outliner.db-pipeline
logseq.outliner.property
logseq.outliner.op.construct
logseq.outliner.recycle
logseq.outliner.tree]
:report {:format :ignore}}

View File

@@ -8,7 +8,10 @@
;; Any other deps should be added here and to nbb.edn
logseq/db {:local/root "../db"}
logseq/graph-parser {:local/root "../graph-parser"}
metosin/malli {:mvn/version "0.16.1"}}
metosin/malli {:mvn/version "0.16.1"}
;; stubbed via logseq.common.log
com.lambdaisland/glogi {:git/url "https://github.com/lambdaisland/glogi"
:git/sha "30328a045141717aadbbb693465aed55f0904976"}}
:aliases
{:clj-kondo
{:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.01.19"}}

View File

@@ -15,13 +15,41 @@
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
[logseq.outliner.datascript :as ds]
[logseq.outliner.pipeline :as outliner-pipeline]
[logseq.outliner.recycle :as outliner-recycle]
[logseq.outliner.transaction :as outliner-tx]
[logseq.outliner.tree :as otree]
[logseq.outliner.tx-meta :as outliner-tx-meta]
[logseq.outliner.validate :as outliner-validate]
[malli.core :as m]
[malli.util :as mu]))
(defn- direct-op-entry
[outliner-op args]
(case outliner-op
:save-block
(let [[_conn block opts] args]
[:save-block [block opts]])
:insert-blocks
(let [[_conn blocks target-block opts] args]
[:insert-blocks [blocks (:db/id target-block) opts]])
:delete-blocks
(let [[_conn blocks opts] args]
[:delete-blocks [(mapv :db/id blocks) opts]])
:move-blocks
(let [[_conn blocks target-block opts] args]
[:move-blocks [(mapv :db/id blocks) (:db/id target-block) opts]])
:move-blocks-up-down
(let [[_conn blocks up?] args]
[:move-blocks-up-down [(mapv :db/id blocks) up?]])
:indent-outdent-blocks
(let [[_conn blocks indent? opts] args]
[:indent-outdent-blocks [(mapv :db/id blocks) indent? opts]])
nil))
(def ^:private block-map
(mu/optional-keys
[:map
@@ -134,11 +162,12 @@
;; Update :block/tag to reference ids from :block/refs
(map (fn [tag]
(if (contains? refs (:block/name tag))
(assoc tag :block/uuid
(:block/uuid
(first (filter (fn [r] (= (:block/name tag)
(let [matched-ref (first (filter (fn [r] (= (:block/name tag)
(:block/name r)))
(:block/refs m)))))
(:block/refs m)))]
(cond-> (assoc tag :block/uuid (:block/uuid matched-ref))
(:db/ident matched-ref)
(assoc :db/ident (:db/ident matched-ref))))
tag))
tags)
@@ -463,15 +492,20 @@
;;; ### insert-blocks, delete-blocks, move-blocks
(defn- get-block-orders
[blocks target-block sibling? keep-block-order?]
[db blocks target-block sibling? keep-block-order? right-sibling-id]
(if (and keep-block-order? (every? :block/order blocks))
(map :block/order blocks)
(let [target-order (:block/order target-block)
next-sibling-order (:block/order (ldb/get-right-sibling target-block))
first-child (ldb/get-down target-block)
first-child-order (:block/order first-child)
start-order (when sibling? target-order)
end-order (if sibling? next-sibling-order first-child-order)
end-order (if sibling?
(let [right-sibling (when right-sibling-id
(d/entity db right-sibling-id))]
(if (= (:db/id (:block/parent right-sibling))
(:db/id (:block/parent target-block)))
(:block/order right-sibling)
(:block/order (ldb/get-right-sibling target-block))))
(let [first-child (ldb/get-down target-block)]
(:block/order first-child)))
orders (db-order/gen-n-keys (count blocks) start-order end-order)]
orders)))
@@ -506,10 +540,10 @@
(:db/id target-block)))
(defn- build-insert-blocks-tx
[db target-block blocks uuids get-new-id {:keys [sibling? outliner-op replace-empty-target? insert-template? keep-block-order?]}]
[db target-block blocks uuids get-new-id {:keys [sibling? outliner-op replace-empty-target? insert-template? keep-block-order? right-sibling-id]}]
(let [block-ids (set (map :block/uuid blocks))
target-page (get-target-block-page target-block sibling?)
orders (get-block-orders blocks target-block sibling? keep-block-order?)]
orders (get-block-orders db blocks target-block sibling? keep-block-order? right-sibling-id)]
(map-indexed (fn [idx {:block/keys [parent] :as block}]
(when-let [uuid' (get uuids (:block/uuid block))]
(let [block (remove-disallowed-inline-classes db block)
@@ -683,7 +717,7 @@
``"
[db blocks target-block {:keys [_sibling? keep-uuid? keep-block-order?
outliner-op outliner-real-op replace-empty-target? update-timestamps?
insert-template?]
insert-template? right-sibling-id]
:as opts
:or {update-timestamps? true}}]
{:pre [(seq blocks)
@@ -732,7 +766,8 @@
:keep-uuid? keep-uuid?
:keep-block-order? keep-block-order?
:outliner-op outliner-op
:insert-template? insert-template?}
:insert-template? insert-template?
:right-sibling-id right-sibling-id}
{:keys [id->new-uuid blocks-tx]} (insert-blocks-aux db blocks' target-block insert-opts)]
(if (some (fn [b] (or (nil? (:block/parent b)) (nil? (:block/order b)))) blocks-tx)
(throw (ex-info "Invalid outliner data"
@@ -797,15 +832,13 @@
(defn ^:api ^:large-vars/cleanup-todo delete-blocks
"Delete blocks from the tree."
[db blocks opts]
(let [{:keys [hard-retract?]} opts
top-level-blocks (filter-top-level-blocks db blocks)
[db blocks _opts]
(let [top-level-blocks (filter-top-level-blocks db blocks)
non-consecutive? (and (> (count top-level-blocks) 1) (seq (ldb/get-non-consecutive-blocks db top-level-blocks)))
top-level-blocks* (get-top-level-blocks top-level-blocks non-consecutive?)
top-level-blocks (->> top-level-blocks*
(remove :logseq.property/built-in?)
(remove ldb/page?))
top-level-blocks (remove :logseq.property/built-in? top-level-blocks*)
txs-state (ds/new-outliner-txs-state)
block-ids (map (fn [b] [:block/uuid (:block/uuid b)]) top-level-blocks)
start-block (first top-level-blocks)
end-block (last top-level-blocks)
delete-one-block? (or (= 1 (count top-level-blocks)) (= start-block end-block))]
@@ -823,15 +856,6 @@
(:db/id (:logseq.property/default-value from-property)))
(not (:block/closed-value-property start-block)))]
(cond
hard-retract?
(let [block-ids (->> top-level-blocks
(mapcat (fn [block]
(map :db/id (ldb/get-block-and-children db (:block/uuid block)
{:include-property-block? true}))))
distinct)
tx-data (map (fn [id] [:db/retractEntity id]) block-ids)]
(when (seq tx-data) (swap! txs-state concat tx-data)))
(and delete-one-block? default-value-property?)
(let [datoms (d/datoms db :avet (:db/ident from-property) (:db/id start-block))
tx-data (map (fn [d] {:db/id (:e d)
@@ -839,8 +863,9 @@
(when (seq tx-data) (swap! txs-state concat tx-data)))
:else
(swap! txs-state concat
(outliner-recycle/recycle-blocks-tx-data db top-level-blocks opts)))))
(doseq [id block-ids]
(let [node (d/entity db id)]
(otree/-del node txs-state db))))))
{:tx-data @txs-state}))
(defn- move-to-original-position?
@@ -924,20 +949,28 @@
(let [parents' (->> (ldb/get-block-parents db (:block/uuid target-block) {})
(map :db/id)
(set))
move-parents-to-child? (some parents' (map :db/id blocks))]
move-parents-to-child? (some parents' (map :db/id blocks))
op-entry [:move-blocks [(mapv :db/id top-level-blocks)
(:db/id target-block)
opts]]]
(when-not move-parents-to-child?
(outliner-tx/with-temp-conn-batch conn {:outliner-op :move-blocks}
(doseq [[idx block] (map vector (range (count blocks)) blocks)]
(let [first-block? (zero? idx)
sibling? (if first-block? sibling? true)
target-block (if first-block? target-block
(d/entity @conn (:db/id (nth blocks (dec idx)))))
block (d/entity @conn (:db/id block))]
(when-not (move-to-original-position? [block] target-block sibling? false)
(let [tx-data (move-block @conn block target-block sibling?)]
;; (prn "==>> move blocks tx:" tx-data)
(ldb/transact! conn tx-data {:sibling? sibling?
:outliner-op (or outliner-op :move-blocks)}))))))
(ldb/batch-transact-with-temp-conn!
conn
{:outliner-op :move-blocks
:outliner-ops [op-entry]}
(fn [conn]
(doseq [[idx block] (map vector (range (count blocks)) blocks)]
(let [first-block? (zero? idx)
sibling? (if first-block? sibling? true)
target-block (if first-block? target-block
(d/entity @conn (:db/id (nth blocks (dec idx)))))
block (d/entity @conn (:db/id block))]
(when-not (move-to-original-position? [block] target-block sibling? false)
(let [tx-data (move-block @conn block target-block sibling?)]
;; FIXME: move-blocks should be pure fn
;; (prn "==>> move blocks tx:" tx-data)
(ldb/transact! conn tx-data {:sibling? sibling?
:outliner-op (or outliner-op :move-blocks)})))))))
nil)))))
(defn- move-blocks-up-down
@@ -1051,7 +1084,10 @@
(try
(let [result (apply f args)]
(when result
(let [tx-meta (assoc (:tx-meta result)
(let [tx-meta (outliner-tx-meta/ensure-outliner-ops
(:tx-meta result)
(direct-op-entry outliner-op args))
tx-meta (assoc tx-meta
:outliner-op outliner-op)]
(ldb/transact! (first args) (:tx-data result) tx-meta)))
result)

View File

@@ -1,10 +1,12 @@
(ns logseq.outliner.op
"Transact outliner ops"
(:require [datascript.core :as d]
(:require [clojure.string :as string]
[datascript.core :as d]
[logseq.common.util :as common-util]
[logseq.db :as ldb]
[logseq.db.sqlite.export :as sqlite-export]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.page :as outliner-page]
[logseq.outliner.property :as outliner-property]
[logseq.outliner.transaction :as outliner-tx]
[malli.core :as m]))
@@ -20,6 +22,10 @@
[:catn
[:op :keyword]
[:args [:tuple ::blocks ::id ::option]]]]
[:apply-template
[:catn
[:op :keyword]
[:args [:tuple ::id ::id ::option]]]]
[:delete-blocks
[:catn
[:op :keyword]
@@ -152,8 +158,6 @@
(def ^:private ops-validator (m/validator ops-schema))
(defonce ^:private *op-handlers (atom {}))
(defn- reaction-user-id
[reaction]
(:db/id (:logseq.property/created-by-ref reaction)))
@@ -186,10 +190,6 @@
{:outliner-op :toggle-reaction})
true)))))
(defn register-op-handlers!
[handlers]
(reset! *op-handlers handlers))
(defn- import-edn-data
[conn *result export-map {:keys [tx-meta] :as import-options}]
(let [{:keys [init-tx block-props-tx misc-tx error] :as _txs}
@@ -207,6 +207,45 @@
(js/console.error "Unexpected Import EDN error:" e)
(reset! *result {:error (str "Unexpected Import EDN error: " (pr-str (ex-message e)))}))))))
(defn- apply-insert-blocks-op!
[conn *result [blocks target-block-id opts]]
(when-let [target-block (d/entity @conn target-block-id)]
(let [result (outliner-core/insert-blocks! conn blocks target-block opts)]
(reset! *result result))))
(defn- template-children-blocks
[db template-id]
(when-let [template (d/entity db template-id)]
(let [template-blocks (some->> (ldb/get-block-and-children db (:block/uuid template)
{:include-property-block? true})
rest)]
(when (seq template-blocks)
(cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template))
(rest template-blocks))))))
(defn- apply-template-op!
[conn *result [template-id target-block-id opts]]
(when-let [target (d/entity @conn target-block-id)]
(let [blocks (template-children-blocks @conn template-id)]
(when (seq blocks)
(let [sibling? (:sibling? opts)
sibling?' (cond
(some? sibling?)
sibling?
(seq (:block/_parent target))
false
:else
true)
result (outliner-core/insert-blocks! conn blocks target
(assoc opts
:sibling? sibling?'
:insert-template? true
:outliner-op :insert-template-blocks))]
(reset! *result result))))))
(defn- ^:large-vars/cleanup-todo apply-op!
[conn opts' *result [op args]]
(case op
@@ -215,10 +254,10 @@
(apply outliner-core/save-block! conn args)
:insert-blocks
(let [[blocks target-block-id opts] args]
(when-let [target-block (d/entity @conn target-block-id)]
(let [result (outliner-core/insert-blocks! conn blocks target-block opts)]
(reset! *result result))))
(apply-insert-blocks-op! conn *result args)
:apply-template
(apply-template-op! conn *result args)
:delete-blocks
(let [[block-ids opts] args
@@ -275,26 +314,76 @@
:class-remove-property
(apply outliner-property/class-remove-property! conn args)
:upsert-closed-value
:upsert-closed-value ; don't support undo/redo
(apply outliner-property/upsert-closed-value! conn args)
:delete-closed-value
:delete-closed-value ; don't support undo/redo
(apply outliner-property/delete-closed-value! conn args)
:add-existing-values-to-closed-values
:add-existing-values-to-closed-values ; don't support undo/redo
(apply outliner-property/add-existing-values-to-closed-values! conn args)
:batch-import-edn
:batch-import-edn ; don't support undo/redo
(apply import-edn-data conn *result args)
:transact
:transact ; don't support undo/redo
(apply ldb/transact! conn args)
:create-page
(let [[title options] args]
(reset! *result (outliner-page/create! conn title (or options {}))))
:rename-page
(let [[page-uuid new-title] args]
(if (string/blank? new-title)
(throw (ex-info "Page name shouldn't be blank" {:block/uuid page-uuid
:block/title new-title}))
(outliner-core/save-block! conn
{:block/uuid page-uuid
:block/title new-title})))
:delete-page
(let [[page-uuid opts] args]
(outliner-page/delete! conn page-uuid (merge opts opts')))
:toggle-reaction
(reset! *result (apply toggle-reaction! conn args))
nil))
(when-let [handler (get @*op-handlers op)]
(reset! *result (handler conn args)))))
(defn- apply-single-op!
[conn ops *result opts' clean-tx-meta]
(let [db @conn
op (first ops)
result (case (ffirst ops)
:save-block
(apply outliner-core/save-block db (second op))
:insert-blocks
(let [[blocks target-block-id insert-opts] (second op)]
(outliner-core/insert-blocks db blocks
(d/entity db target-block-id)
insert-opts))
:delete-blocks
(let [[block-ids opts] (second op)
blocks (keep #(d/entity db %) block-ids)]
(outliner-core/delete-blocks db blocks (merge opts opts'))))
additional-tx (:additional-tx opts')
full-tx (concat (:tx-data result) additional-tx)]
(ldb/transact! conn full-tx clean-tx-meta)
(reset! *result result)))
(defn- apply-save-followed-by-insert!
[conn ops *result opts' clean-tx-meta]
(let [save-block-tx (:tx-data (apply outliner-core/save-block @conn (second (first ops))))
[blocks target-block-id insert-opts] (second (second ops))
insert-blocks-result (outliner-core/insert-blocks @conn blocks
(d/entity @conn target-block-id)
insert-opts)
additional-tx (:additional-tx opts')
full-tx (concat save-block-tx
(:tx-data insert-blocks-result)
additional-tx)]
(ldb/transact! conn full-tx clean-tx-meta)
(reset! *result insert-blocks-result)))
(defn apply-ops!
[conn ops opts]
@@ -303,14 +392,28 @@
(first (first ops)))
opts' (cond-> (assoc opts
:transact-opts {:conn conn}
:local-tx? true)
:local-tx? true
:outliner-ops ops
:db-sync/tx-id (or (:db-sync/tx-id opts) (random-uuid)))
(and single-op-outliner-op
(nil? (:outliner-op opts)))
(assoc :outliner-op single-op-outliner-op))
*result (atom nil)]
(outliner-tx/transact!
opts'
(doseq [op-entry ops]
(apply-op! conn opts' *result op-entry)))
*result (atom nil)
clean-tx-meta (dissoc opts' :additional-tx :transact-opts :current-block)]
(cond
(and single-op-outliner-op
(contains? #{:save-block :insert-blocks :delete-blocks} (ffirst ops)))
(apply-single-op! conn ops *result opts' clean-tx-meta)
(and (= 2 (count ops))
(= :save-block (ffirst ops))
(= :insert-blocks (first (second ops))))
(apply-save-followed-by-insert! conn ops *result opts' clean-tx-meta)
:else
(outliner-tx/transact!
opts'
(doseq [op-entry ops]
(apply-op! conn opts' *result op-entry))))
@*result))

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,11 @@
[logseq.graph-parser.block :as gp-block]
[logseq.graph-parser.text :as text]
[logseq.outliner.recycle :as outliner-recycle]
[logseq.outliner.tx-meta :as outliner-tx-meta]
[logseq.outliner.validate :as outliner-validate]))
(defn- db-refs->page
"Replace [[page name]] with page name"
(defn- page-ref-rewrite-targets
"Collect entities that reference `page-entity` via node refs and need title rewrite."
[page-entity]
(let [refs (->> (:block/_refs page-entity)
;; remove child or self that refed this page
@@ -30,24 +31,49 @@
(or (= (:db/id ref) (:db/id page-entity))
(= (:db/id (:block/page ref)) (:db/id page-entity))))))
id-ref->page #(db-content/content-id-ref->page % [page-entity])]
(when (seq refs)
(let [tx-data (mapcat (fn [{:block/keys [raw-title] :as ref}]
;; block content
(when raw-title
(let [content' (id-ref->page raw-title)
content-tx (when (not= raw-title content')
{:db/id (:db/id ref)
:block/title content'})
tx content-tx]
(concat
[[:db/retract (:db/id ref) :block/refs (:db/id page-entity)]]
(when tx [tx]))))) refs)]
tx-data))))
(->> refs
(keep (fn [ref]
(let [raw-title (:block/raw-title ref)
block-uuid (:block/uuid ref)]
(when raw-title
(let [content' (id-ref->page raw-title)]
(when (not= raw-title content')
(let [remaining-refs (->> (:block/refs ref)
(remove (fn [ref']
(= (:db/id ref') (:db/id page-entity))))
vec)]
{:ref-id (:db/id ref)
:ref-uuid block-uuid
:title content'
:refs remaining-refs})))))))
seq)))
(defn- db-refs->page
"Replace [[page name]] with page name."
[page-entity]
(let [page-id (:db/id page-entity)]
(some->> (page-ref-rewrite-targets page-entity)
(mapcat (fn [{:keys [ref-id title]}]
[[:db/retract ref-id :block/refs page-id]
{:db/id ref-id
:block/title title}])))))
(defn- db-refs->page-save-ops
[page-entity]
(some->> (page-ref-rewrite-targets page-entity)
(keep (fn [{:keys [ref-uuid title refs]}]
(when ref-uuid
[:save-block [{:block/uuid ref-uuid
:block/title title
:block/refs refs}
{}]])))
seq
vec))
(defn- build-page-retract-tx
"Build cleanup tx-data for deleting a schema page.
This is pure and can be reused by sync repair."
[db page & [{:keys [include-page-retract?]
[db page & [{:keys [include-page-retract? today-page?]
:or {include-page-retract? true}}]]
(let [page-id (:db/id page)
page-blocks-tx-data (->> (:block/_page page)
@@ -65,11 +91,13 @@
page-tx (when (and include-page-retract?
(d/entity db page-id))
[[:db/retractEntity page-id]])]
(concat page-blocks-tx-data
property-pair-tx-data
restore-class-parent-tx
(db-refs->page page)
page-tx)))
(if today-page?
page-blocks-tx-data
(concat page-blocks-tx-data
property-pair-tx-data
restore-class-parent-tx
(db-refs->page page)
page-tx))))
(defn delete!
"Deletes a page. Returns true if able to delete page. If unable to delete,
@@ -86,31 +114,37 @@
(when-let [page (d/entity @conn [:block/uuid page-uuid])]
(let [today-page? (when-let [day (:block/journal-day page)]
(= (date-time-util/ms->journal-day (js/Date.)) day))
tx-meta (cond-> {:outliner-op :delete-page
:deleted-page (:block/title page)
:persist-op? persist-op?}
tx-meta (cond-> (outliner-tx-meta/ensure-outliner-ops
{:outliner-op :delete-page
:deleted-page (:block/title page)
:persist-op? persist-op?}
[:delete-page [page-uuid {:deleted-by-uuid deleted-by-uuid
:now-ms now-ms}]])
rename?
(assoc :real-outliner-op :rename-page))]
;; TODO: maybe we should add $$$favorites to built-in pages?
(cond
today-page?
false
(or (ldb/built-in? page) (ldb/hidden? page))
(do
(error-handler {:msg "Built-in page cannot be deleted"})
false)
(or (ldb/class? page) (ldb/property? page))
(let [tx-data (build-page-retract-tx @conn page)]
(or (ldb/class? page) (ldb/property? page) today-page?)
(let [tx-data (build-page-retract-tx @conn page {:today-page? today-page?})]
(ldb/transact! conn tx-data tx-meta)
true)
:else
(let [tx-data (outliner-recycle/recycle-page-tx-data @conn page {:deleted-by-uuid deleted-by-uuid
:now-ms now-ms})]
(let [ref-rewrite-tx-data (db-refs->page page)
ref-rewrite-save-ops (db-refs->page-save-ops page)
tx-data (concat ref-rewrite-tx-data
(outliner-recycle/recycle-page-tx-data @conn page {:deleted-by-uuid deleted-by-uuid
:now-ms now-ms}))
tx-meta' (cond-> tx-meta
(seq ref-rewrite-save-ops)
(update :outliner-ops (fnil into []) ref-rewrite-save-ops))]
(when (seq tx-data)
(ldb/transact! conn tx-data tx-meta))
(ldb/transact! conn tx-data tx-meta'))
true))))))
(defn- build-page-tx [db properties page {:keys [class? tags class-ident-namespace]}]
@@ -289,22 +323,26 @@
:block/uuid)
existing-page-by-journal-uuid (when (uuid? journal-page-uuid)
(d/entity db [:block/uuid journal-page-uuid]))
existing-page-id (some->> existing-names-page
(filter #(try (when-let [e (and class-ident-namespace? (d/entity db %))]
(let [ns' (namespace (:db/ident e))]
(= (str ns') class-ident-namespace)))
(catch :default _ false)))
(first))
existing-page-id (if class-ident-namespace?
(some->> existing-names-page
(filter #(try (when-let [e (d/entity db %)]
(let [ns' (namespace (:db/ident e))]
(= (str ns') class-ident-namespace)))
(catch :default _ false)))
(first))
(first existing-names-page))
existing-page (or (some->> existing-page-id (d/entity db))
existing-page-by-journal-uuid)]
(if (and existing-page
(or (:block/journal-day existing-page)
(not (:block/parent existing-page))))
(not (:block/parent existing-page))
(ldb/recycled? existing-page)))
(let [tx-meta {:persist-op? persist-op?
:outliner-op :save-block}]
(if (and class?
(not (ldb/class? existing-page))
(ldb/internal-page? existing-page))
(cond
(and class?
(not (ldb/class? existing-page))
(ldb/internal-page? existing-page))
;; Convert existing page to class
(let [tx-data [(merge (db-class/build-new-class db
(select-keys existing-page [:block/title :block/uuid :block/created-at])
@@ -316,7 +354,20 @@
:tx-data tx-data
:page-uuid (:block/uuid existing-page)
:title (:block/title existing-page)})
(ldb/recycled? existing-page)
(let [options' (assoc options :uuid (:block/uuid existing-page))
tx-meta' (outliner-tx-meta/ensure-outliner-ops
{:persist-op? persist-op?
:outliner-op :create-page}
[:create-page [title options']])]
{:tx-meta tx-meta'
:tx-data (outliner-recycle/restore-tx-data db existing-page)
:page-uuid (:block/uuid existing-page)
:title (:block/title existing-page)})
;; Just return existing page info
:else
{:page-uuid (:block/uuid existing-page)
:title (:block/title existing-page)}))
(let [page (gp-block/page-name->map title db true date-formatter
@@ -343,8 +394,10 @@
;; transact doesn't support entities
(remove de/entity? parents')
page-txs)
tx-meta (cond-> {:persist-op? persist-op?
:outliner-op :create-page}
tx-meta (cond-> (outliner-tx-meta/ensure-outliner-ops
{:persist-op? persist-op?
:outliner-op :create-page}
[:create-page [title options]])
today-journal?
(assoc :create-today-journal? true
:today-journal-name title))]

View File

@@ -19,6 +19,7 @@
[logseq.db.sqlite.util :as sqlite-util]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.page :as outliner-page]
[logseq.outliner.tx-meta :as outliner-tx-meta]
[logseq.outliner.validate :as outliner-validate]
[malli.core :as m]
[malli.error :as me]
@@ -273,6 +274,16 @@
[id]
(if (uuid? id) [:block/uuid id] id))
(defn- with-op-entry
[op-entry f]
(binding [outliner-tx-meta/*outliner-op-entry*
(or outliner-tx-meta/*outliner-op-entry* op-entry)]
(f)))
(defn- transact-with-op!
[conn tx-data tx-meta]
(ldb/transact! conn tx-data (outliner-tx-meta/ensure-outliner-ops tx-meta nil)))
(defn- raw-set-block-property!
"Adds the raw property pair (value not modified) to the given block if the property value is valid"
[conn block property new-value]
@@ -280,31 +291,34 @@
(throw-error-if-invalid-property-value @conn property new-value)
(let [property-id (:db/ident property)
tx-data (build-property-value-tx-data conn block property-id new-value)]
(ldb/transact! conn tx-data {:outliner-op :save-block})))
(transact-with-op! conn tx-data {:outliner-op :save-block})))
(defn create-property-text-block!
"Creates a property value block for the given property and value. Adds it to
block if given block."
[conn block-id property-id value {:keys [new-block-id]}]
(let [property (d/entity @conn property-id)
block (when block-id (d/entity @conn block-id))
_ (assert (some? property) (str "Property " property-id " doesn't exist yet"))
value' (convert-property-input-string (:logseq.property/type block)
property value)
_ (when (and (not= (:logseq.property/type property) :number)
(not (string? value')))
(throw (ex-info "value should be a string" {:block-id block-id
:property-id property-id
:value value'})))
new-value-block (cond-> (db-property-build/build-property-value-block (or block property) property value')
new-block-id
(assoc :block/uuid new-block-id))]
(ldb/transact! conn [new-value-block] {:outliner-op :insert-blocks})
(let [property-id (:db/ident property)]
(when (and property-id block)
(when-let [block-id (:db/id (d/entity @conn [:block/uuid (:block/uuid new-value-block)]))]
(raw-set-block-property! conn block property block-id)))
(:block/uuid new-value-block))))
(with-op-entry
[:create-property-text-block [block-id property-id value {:new-block-id new-block-id}]]
(fn []
(let [property (d/entity @conn property-id)
block (when block-id (d/entity @conn block-id))
_ (assert (some? property) (str "Property " property-id " doesn't exist yet"))
value' (convert-property-input-string (:logseq.property/type block)
property value)
_ (when (and (not= (:logseq.property/type property) :number)
(not (string? value')))
(throw (ex-info "value should be a string" {:block-id block-id
:property-id property-id
:value value'})))
new-value-block (cond-> (db-property-build/build-property-value-block (or block property) property value')
new-block-id
(assoc :block/uuid new-block-id))]
(transact-with-op! conn [new-value-block] {:outliner-op :insert-blocks})
(let [property-id (:db/ident property)]
(when (and property-id block)
(when-let [block-id (:db/id (d/entity @conn [:block/uuid (:block/uuid new-value-block)]))]
(raw-set-block-property! conn block property block-id)))
(:block/uuid new-value-block))))))
(defn- get-property-value-eid
[db property-id raw-value]
@@ -404,36 +418,56 @@
(defn batch-remove-property!
[conn block-ids property-id]
(throw-error-if-read-only-property property-id)
(let [block-eids (map ->eid block-ids)
blocks (keep (fn [id] (d/entity @conn id)) block-eids)
block-id-set (set (map :db/id blocks))]
(validate-batch-deletion-of-property blocks property-id)
(when (seq blocks)
(when-let [property (d/entity @conn property-id)]
(let [txs (mapcat
(fn [block]
(let [value (get block property-id)
entities (cond
(de/entity? value) [value]
(and (sequential? value) (every? de/entity? value)) value
:else nil)
deleting-entities (filter
(fn [value]
(and
(:logseq.property/created-from-property value)
(not (or (entity-util/page? value) (ldb/closed-value? value)))
(empty? (set/difference (set (map :e (d/datoms @conn :avet (:db/ident property) (:db/id value)))) block-id-set))))
entities)
;; Delete property value block if it's no longer used by other blocks
retract-blocks-tx (when (seq deleting-entities)
(:tx-data (outliner-core/delete-blocks @conn deleting-entities {})))]
(concat
[[:db/retract (:db/id block) (:db/ident property)]]
retract-blocks-tx)))
blocks)]
(when (seq txs)
(ldb/transact! conn txs {:outliner-op :save-block})))))))
(with-op-entry
[:batch-remove-property [block-ids property-id]]
(fn []
(throw-error-if-read-only-property property-id)
(let [block-eids (map ->eid block-ids)
blocks (keep (fn [id] (d/entity @conn id)) block-eids)
block-id-set (set (map :db/id blocks))]
(validate-batch-deletion-of-property blocks property-id)
(when (seq blocks)
(when-let [property (d/entity @conn property-id)]
(let [txs (mapcat
(fn [block]
(let [value (get block property-id)
entities (cond
(de/entity? value) [value]
(and (sequential? value) (every? de/entity? value)) value
:else nil)
deleting-entities (filter
(fn [value]
(let [value-referrers*
(d/q '[:find [?e ...]
:in $ ?property-id ?value-id
:where
[?e ?property-id ?value-id]]
@conn
(:db/ident property)
(:db/id value))
value-referrers
(cond
(nil? value-referrers*)
#{}
(coll? value-referrers*)
(set value-referrers*)
:else
#{value-referrers*})]
(and
(:logseq.property/created-from-property value)
(not (or (entity-util/page? value) (ldb/closed-value? value)))
(empty? (set/difference value-referrers block-id-set)))))
entities)
retract-blocks-tx (when (seq deleting-entities)
(:tx-data (outliner-core/delete-blocks @conn deleting-entities {})))]
(concat
[[:db/retract (:db/id block) (:db/ident property)]]
retract-blocks-tx)))
blocks)]
(when (seq txs)
(transact-with-op! conn txs {:outliner-op :save-block})))))))))
(defn batch-set-property!
"Sets properties for multiple blocks. Automatically handles property value refs.
@@ -441,90 +475,96 @@
([conn block-ids property-id v]
(batch-set-property! conn block-ids property-id v {}))
([conn block-ids property-id v options]
(assert property-id "property-id is nil")
(throw-error-if-read-only-property property-id)
(if (nil? v)
(batch-remove-property! conn block-ids property-id)
(let [block-eids (map ->eid block-ids)
_ (when (= property-id :block/tags)
(outliner-validate/validate-tags-property @conn block-eids v))
property (d/entity @conn property-id)
_ (when (= (:db/ident property) :logseq.property.class/extends)
(outliner-validate/validate-extends-property
@conn
(if (number? v) (d/entity @conn v) v)
(map #(d/entity @conn %) block-eids)))
_ (when (nil? property)
(throw (ex-info (str "Property " property-id " doesn't exist yet") {:property-id property-id})))
property-type (get property :logseq.property/type :default)
entity-id? (and (:entity-id? options) (number? v))
ref? (contains? db-property-type/all-ref-property-types property-type)
default-url-not-closed? (and (contains? #{:default :url} property-type)
(not (seq (entity-plus/lookup-kv-then-entity property :property/closed-values))))
v' (if (and ref? (not entity-id?))
(convert-ref-property-value conn property-id v property-type)
v)
_ (when (nil? v')
(throw (ex-info "Property value must be not nil" {:v v})))
txs (doall
(mapcat
(fn [eid]
(if-let [block (d/entity @conn eid)]
(let [v' (if (and default-url-not-closed?
(not (and (keyword? v) entity-id?)))
(do
(when (number? v')
(throw-error-if-invalid-property-value @conn property v'))
(let [v (if (number? v') (:block/title (d/entity @conn v')) v')]
(convert-ref-property-value conn property-id v property-type)))
v')]
(throw-error-if-self-value block v' ref?)
(throw-error-if-invalid-property-value @conn property v')
(build-property-value-tx-data conn block property-id v'))
(js/console.error "Skipping setting a block's property because the block id could not be found:" eid)))
block-eids))]
(when (seq txs)
(ldb/transact! conn txs {:outliner-op :save-block}))))))
(with-op-entry
[:batch-set-property [block-ids property-id v options]]
(fn []
(assert property-id "property-id is nil")
(throw-error-if-read-only-property property-id)
(if (nil? v)
(batch-remove-property! conn block-ids property-id)
(let [block-eids (map ->eid block-ids)
_ (when (= property-id :block/tags)
(outliner-validate/validate-tags-property @conn block-eids v))
property (d/entity @conn property-id)
_ (when (= (:db/ident property) :logseq.property.class/extends)
(outliner-validate/validate-extends-property
@conn
(if (number? v) (d/entity @conn v) v)
(map #(d/entity @conn %) block-eids)))
_ (when (nil? property)
(throw (ex-info (str "Property " property-id " doesn't exist yet") {:property-id property-id})))
property-type (get property :logseq.property/type :default)
entity-id? (and (:entity-id? options) (number? v))
ref? (contains? db-property-type/all-ref-property-types property-type)
default-url-not-closed? (and (contains? #{:default :url} property-type)
(not (seq (entity-plus/lookup-kv-then-entity property :property/closed-values))))
v' (if (and ref? (not entity-id?))
(convert-ref-property-value conn property-id v property-type)
v)
_ (when (nil? v')
(throw (ex-info "Property value must be not nil" {:v v})))
txs (doall
(mapcat
(fn [eid]
(if-let [block (d/entity @conn eid)]
(let [v' (if (and default-url-not-closed?
(not (and (keyword? v) entity-id?)))
(do
(when (number? v')
(throw-error-if-invalid-property-value @conn property v'))
(let [v (if (number? v') (:block/title (d/entity @conn v')) v')]
(convert-ref-property-value conn property-id v property-type)))
v')]
(throw-error-if-self-value block v' ref?)
(throw-error-if-invalid-property-value @conn property v')
(build-property-value-tx-data conn block property-id v'))
(js/console.error "Skipping setting a block's property because the block id could not be found:" eid)))
block-eids))]
(when (seq txs)
(transact-with-op! conn txs {:outliner-op :save-block}))))))))
(defn remove-block-property!
[conn eid property-id]
(throw-error-if-read-only-property property-id)
(let [eid (->eid eid)
block (d/entity @conn eid)
property (d/entity @conn property-id)]
;; Can skip for extends b/c below tx ensures it has a default value
(when-not (= :logseq.property.class/extends property-id)
(validate-batch-deletion-of-property [block] property-id))
(when block
(cond
(= :logseq.property/empty-placeholder (:db/ident (get block property-id)))
nil
(with-op-entry
[:remove-block-property [eid property-id]]
(fn []
(throw-error-if-read-only-property property-id)
(let [eid (->eid eid)
block (d/entity @conn eid)
property (d/entity @conn property-id)]
(when-not (= :logseq.property.class/extends property-id)
(validate-batch-deletion-of-property [block] property-id))
(when block
(cond
(= :logseq.property/empty-placeholder (:db/ident (get block property-id)))
nil
(= :logseq.property/status property-id)
(ldb/transact! conn
[[:db/retract (:db/id block) property-id]
[:db/retract (:db/id block) :block/tags :logseq.class/Task]]
{:outliner-op :save-block})
(= :logseq.property/status property-id)
(transact-with-op! conn
[[:db/retract (:db/id block) property-id]
[:db/retract (:db/id block) :block/tags :logseq.class/Task]]
{:outliner-op :save-block})
(and (:logseq.property/default-value property)
(= (:logseq.property/default-value property) (get block property-id)))
(ldb/transact! conn
[{:db/id (:db/id block)
property-id :logseq.property/empty-placeholder}]
{:outliner-op :save-block})
(and (:logseq.property/default-value property)
(= (:logseq.property/default-value property) (get block property-id)))
(transact-with-op! conn
[{:db/id (:db/id block)
property-id :logseq.property/empty-placeholder}]
{:outliner-op :save-block})
(and (ldb/class? block) (= property-id :logseq.property.class/extends))
(ldb/transact! conn
[[:db/retract (:db/id block) :logseq.property.class/extends]
[:db/add (:db/id block) :logseq.property.class/extends :logseq.class/Root]]
{:outliner-op :save-block})
(and (ldb/class? block) (= property-id :logseq.property.class/extends))
(transact-with-op! conn
[[:db/retract (:db/id block) :logseq.property.class/extends]
[:db/add (:db/id block) :logseq.property.class/extends :logseq.class/Root]]
{:outliner-op :save-block})
(contains? db-property/db-attribute-properties property-id)
(ldb/transact! conn
[[:db/retract (:db/id block) property-id]]
{:outliner-op :save-block})
:else
(batch-remove-property! conn [eid] property-id)))))
(contains? db-property/db-attribute-properties property-id)
(transact-with-op! conn
[[:db/retract (:db/id block) property-id]]
{:outliner-op :save-block})
:else
(batch-remove-property! conn [eid] property-id)))))))
(defn- set-block-db-attribute!
[conn db block property property-id v]
@@ -534,125 +574,150 @@
[{:db/id (:db/id block) property-id v}]
(= property-id :logseq.property.class/extends)
(conj [:db/retract (:db/id block) :logseq.property.class/extends :logseq.class/Root]))]
(ldb/transact! conn tx-data
{:outliner-op :save-block}))))
(transact-with-op! conn tx-data
{:outliner-op :save-block}))))
(defn set-block-property!
(defn ^:large-vars/cleanup-todo set-block-property!
"Updates a block property's value for an existing property-id and block. If
property is a ref type, automatically handles a raw property value i.e. you
can pass \"value\" instead of the property value entity. Also handle db
attributes as properties"
[conn block-eid property-id v]
(throw-error-if-read-only-property property-id)
(let [db @conn
block-eid (->eid block-eid)
_ (assert (qualified-keyword? property-id) "property-id should be a keyword")
block (d/entity @conn block-eid)
db-attribute? (some? (db-schema/schema property-id))
property (d/entity @conn property-id)
property-type (get property :logseq.property/type :default)
ref? (db-property-type/all-ref-property-types property-type)
v' (if ref?
(convert-ref-property-value conn property-id v property-type)
v)]
(when-not (and block property)
(throw (ex-info "Set block property failed: block or property doesn't exist"
{:block-eid block-eid
:property-id property-id
:block block
:property property})))
(if (nil? v')
(remove-block-property! conn block-eid property-id)
(do
(when (= property-id :block/tags)
(outliner-validate/validate-tags-property @conn [block-eid] v'))
(when (= property-id :logseq.property.class/extends)
(outliner-validate/validate-extends-property @conn v' [block]))
(cond
db-attribute?
(set-block-db-attribute! conn db block property property-id v)
(with-op-entry
[:set-block-property [block-eid property-id v]]
(fn []
(throw-error-if-read-only-property property-id)
(let [db @conn
block-eid (->eid block-eid)
_ (assert (qualified-keyword? property-id) "property-id should be a keyword")
block (d/entity @conn block-eid)
db-attribute? (some? (db-schema/schema property-id))
property (d/entity @conn property-id)
property-type (get property :logseq.property/type :default)
ref? (db-property-type/all-ref-property-types property-type)
v' (if ref?
(convert-ref-property-value conn property-id v property-type)
v)]
(when-not (and block property)
(throw (ex-info "Set block property failed: block or property doesn't exist"
{:block-eid block-eid
:property-id property-id
:block block
:property property})))
(if (nil? v')
(remove-block-property! conn block-eid property-id)
(do
(when (= property-id :block/tags)
(outliner-validate/validate-tags-property @conn [block-eid] v'))
(when (= property-id :logseq.property.class/extends)
(outliner-validate/validate-extends-property @conn v' [block]))
(cond
db-attribute?
(set-block-db-attribute! conn db block property property-id v)
:else
(let [_ (assert (some? property) (str "Property " property-id " doesn't exist yet"))
ref? (db-property-type/all-ref-property-types property-type)
existing-value (get block property-id)
many? (= :db.cardinality/many (:db/cardinality property))
value-matches? (if ref?
(if (and many? (coll? v'))
(= (set (map :db/id existing-value)) (set v'))
(= existing-value v'))
(= existing-value v'))]
(throw-error-if-self-value block v' ref?)
(when-not value-matches?
(raw-set-block-property! conn block property v'))))))))
:else
(let [_ (assert (some? property) (str "Property " property-id " doesn't exist yet"))
ref? (db-property-type/all-ref-property-types property-type)
existing-value (get block property-id)
many? (= :db.cardinality/many (:db/cardinality property))
many-ref-value-ids (fn [value]
(->> (cond
(nil? value) []
(de/entity? value) [value]
(sequential? value) value
:else [value])
(map (fn [item]
(if (de/entity? item)
(:db/id item)
item)))
set))
value-matches? (if ref?
(if (and many? (coll? v'))
(= (many-ref-value-ids existing-value)
(many-ref-value-ids v'))
(= existing-value v'))
(= existing-value v'))]
(throw-error-if-self-value block v' ref?)
(when-not value-matches?
(raw-set-block-property! conn block property v'))))))))))
(defn upsert-property!
"Updates property if property-id is given. Otherwise creates a property
with the given property-id or :property-name option. When a property is created
it is ensured to have a unique :db/ident"
with the given property-id or :property-name option. When a property is created
it is ensured to have a unique :db/ident"
[conn property-id schema {:keys [property-name properties] :as opts}]
(let [db @conn
db-ident (or property-id
(try (db-property/create-user-property-ident-from-name property-name)
(catch :default e
(throw (ex-info (str e)
{:type :notification
:payload {:message "Property failed to create. Please try a different property name."
:type :error}})))))]
(assert (qualified-keyword? db-ident))
(when (and (contains? #{:checkbox} (:logseq.property/type schema))
(= :db.cardinality/many (:db/cardinality schema)))
(throw (ex-info ":checkbox property doesn't allow multiple values" {:property-id property-id
:schema schema})))
(if-let [property (and (qualified-keyword? property-id) (d/entity db db-ident))]
(update-property conn db-ident property schema opts)
(let [k-name (or (and property-name (name property-name))
(name property-id))
db-ident' (db-ident/ensure-unique-db-ident @conn db-ident)]
(assert (some? k-name)
(prn "property-id: " property-id ", property-name: " property-name))
(outliner-validate/validate-page-title k-name {:node {:db/ident db-ident'}})
(outliner-validate/validate-page-title-characters k-name {:node {:db/ident db-ident'}})
(let [db-id (:db/id properties)
opts (cond-> {:title k-name
:properties properties}
(integer? db-id)
(assoc :block-uuid (:block/uuid (d/entity db db-id))))]
(ldb/transact! conn
(concat
[(sqlite-util/build-new-property db-ident' schema opts)]
;; Convert page to property
(when db-id
[[:db/retract db-id :block/tags :logseq.class/Page]]))
{:outliner-op :upsert-property}))
(d/entity @conn db-ident')))))
(with-op-entry
[:upsert-property [property-id schema opts]]
(fn []
(let [db @conn
db-ident (or property-id
(try (db-property/create-user-property-ident-from-name property-name)
(catch :default e
(throw (ex-info (str e)
{:type :notification
:payload {:message "Property failed to create. Please try a different property name."
:type :error}})))))]
(assert (qualified-keyword? db-ident))
(when (and (contains? #{:checkbox} (:logseq.property/type schema))
(= :db.cardinality/many (:db/cardinality schema)))
(throw (ex-info ":checkbox property doesn't allow multiple values"
{:property-id property-id
:schema schema})))
(if-let [property (and (qualified-keyword? property-id) (d/entity db db-ident))]
(update-property conn db-ident property schema opts)
(let [k-name (or (and property-name (name property-name))
(name property-id))
db-ident' (db-ident/ensure-unique-db-ident @conn db-ident)]
(assert (some? k-name)
(prn "property-id: " property-id ", property-name: " property-name))
(outliner-validate/validate-page-title k-name {:node {:db/ident db-ident'}})
(outliner-validate/validate-page-title-characters k-name {:node {:db/ident db-ident'}})
(let [db-id (:db/id properties)
opts' (cond-> {:title k-name
:properties properties}
(integer? db-id)
(assoc :block-uuid (:block/uuid (d/entity db db-id))))]
(transact-with-op! conn
(concat
[(sqlite-util/build-new-property db-ident' schema opts')]
(when db-id
[[:db/retract db-id :block/tags :logseq.class/Page]]))
{:outliner-op :upsert-property}))
(d/entity @conn db-ident')))))))
(defn batch-delete-property-value!
"batch delete value when a property has multiple values"
[conn block-eids property-id property-value]
(when-let [property (d/entity @conn property-id)]
(when (and (db-property/many? property)
(not (some #(= property-id (:db/ident (d/entity @conn %))) block-eids)))
(when (= property-id :block/tags)
(outliner-validate/validate-tags-property-deletion @conn block-eids property-value))
(if (= property-id :block/tags)
(let [tx-data (map (fn [id] [:db/retract id property-id property-value]) block-eids)]
(ldb/transact! conn tx-data {:outliner-op :save-block}))
(doseq [block-eid block-eids]
(when-let [block (d/entity @conn block-eid)]
(let [current-val (get block property-id)
fv (first current-val)]
(if (and (= 1 (count current-val)) (or (= property-value fv) (= property-value (:db/id fv))))
(remove-block-property! conn (:db/id block) property-id)
(ldb/transact! conn
[[:db/retract (:db/id block) property-id property-value]]
{:outliner-op :save-block})))))))))
(with-op-entry
[:batch-delete-property-value [block-eids property-id property-value]]
(fn []
(when-let [property (d/entity @conn property-id)]
(when (and (db-property/many? property)
(not (some #(= property-id (:db/ident (d/entity @conn %))) block-eids)))
(when (= property-id :block/tags)
(outliner-validate/validate-tags-property-deletion @conn block-eids property-value))
(if (= property-id :block/tags)
(let [tx-data (map (fn [id] [:db/retract id property-id property-value]) block-eids)]
(transact-with-op! conn tx-data {:outliner-op :save-block}))
(doseq [block-eid block-eids]
(when-let [block (d/entity @conn block-eid)]
(let [current-val (get block property-id)
fv (first current-val)]
(if (and (= 1 (count current-val))
(or (= property-value fv)
(= property-value (:db/id fv))))
(remove-block-property! conn (:db/id block) property-id)
(transact-with-op! conn
[[:db/retract (:db/id block) property-id property-value]]
{:outliner-op :save-block})))))))))))
(defn delete-property-value!
"Delete value if a property has multiple values"
[conn block-eid property-id property-value]
(batch-delete-property-value! conn [block-eid] property-id property-value))
(with-op-entry
[:delete-property-value [block-eid property-id property-value]]
(fn []
(batch-delete-property-value! conn [block-eid] property-id property-value))))
(defn ^:api get-classes-parents
[tags]
@@ -765,108 +830,120 @@
(defn upsert-closed-value!
"id should be a block UUID or nil"
[conn property-id {:keys [id value description _scoped-class-id] :as opts}]
(assert (or (nil? id) (uuid? id)))
(let [db @conn
property (d/entity db property-id)
property-type (:logseq.property/type property)]
(when (contains? db-property-type/closed-value-property-types property-type)
(let [value' (if (string? value) (string/trim value) value)
resolved-value (convert-property-input-string nil property value')
validate-message (validate-property-value-aux
(get-property-value-schema @conn property-type property {:new-closed-value? true})
resolved-value
{:many? (db-property/many? property)})]
(cond
(some (fn [b]
(and (= (str resolved-value) (str (or (db-property/closed-value-content b)
(:block/uuid b))))
(not= id (:block/uuid b))))
(entity-plus/lookup-kv-then-entity property :property/closed-values))
(with-op-entry
[:upsert-closed-value [property-id opts]]
(fn []
(assert (or (nil? id) (uuid? id)))
(let [db @conn
property (d/entity db property-id)
property-type (:logseq.property/type property)]
(when (contains? db-property-type/closed-value-property-types property-type)
(let [value' (if (string? value) (string/trim value) value)
resolved-value (convert-property-input-string nil property value')
validate-message (validate-property-value-aux
(get-property-value-schema @conn property-type property {:new-closed-value? true})
resolved-value
{:many? (db-property/many? property)})]
(cond
(some (fn [b]
(and (= (str resolved-value) (str (or (db-property/closed-value-content b)
(:block/uuid b))))
(not= id (:block/uuid b))))
(entity-plus/lookup-kv-then-entity property :property/closed-values))
(throw (ex-info "Closed value choice already exists"
{:error :value-exists
:type :notification
:payload {:message "Choice already exists"
:type :warning}}))
;; Make sure to update frontend.handler.db-based.property-test when updating ex-info message
(throw (ex-info "Closed value choice already exists"
{:error :value-exists
:type :notification
:payload {:message "Choice already exists"
:type :warning}}))
validate-message
(throw (ex-info "Invalid property value"
{:error :value-invalid
:type :notification
:payload {:message validate-message
:type :warning}}))
validate-message
;; Make sure to update frontend.handler.db-based.property-test when updating ex-info message
(throw (ex-info "Invalid property value"
{:error :value-invalid
:type :notification
:payload {:message validate-message
:type :warning}}))
(nil? resolved-value)
nil
(nil? resolved-value)
nil
:else
(let [tx-data (build-closed-value-tx @conn property resolved-value opts)]
(ldb/transact! conn tx-data {:outliner-op :save-block})
(when (seq description)
(if-let [desc-ent (and id (:logseq.property/description (d/entity db [:block/uuid id])))]
(ldb/transact! conn
[(outliner-core/block-with-updated-at {:db/id (:db/id desc-ent)
:block/title description})]
{:outliner-op :save-block})
(set-block-property! conn
;; new closed value is first in tx-data
[:block/uuid (or id (:block/uuid (first tx-data)))]
:logseq.property/description
description)))))))))
:else
(let [tx-data (build-closed-value-tx @conn property resolved-value opts)]
(transact-with-op! conn tx-data {:outliner-op :insert-blocks})
(when (seq description)
(if-let [desc-ent (and id (:logseq.property/description (d/entity db [:block/uuid id])))]
(transact-with-op! conn
[(outliner-core/block-with-updated-at {:db/id (:db/id desc-ent)
:block/title description})]
{:outliner-op :save-block})
(set-block-property! conn
[:block/uuid (or id (:block/uuid (first tx-data)))]
:logseq.property/description
description)))))))))))
(defn add-existing-values-to-closed-values!
"Adds existing values as closed values and returns their new block uuids"
[conn property-id values]
(when-let [property (d/entity @conn property-id)]
(when (seq values)
(let [values' (remove string/blank? values)]
(assert (every? uuid? values') "existing values should all be UUIDs")
(let [values (keep #(d/entity @conn [:block/uuid %]) values')]
(when (seq values)
(let [value-property-tx (map (fn [id]
{:db/id id
:block/closed-value-property (:db/id property)})
(map :db/id values))
property-tx (outliner-core/block-with-updated-at {:db/id (:db/id property)})]
(ldb/transact! conn (cons property-tx value-property-tx)
{:outliner-op :save-blocks}))))))))
(with-op-entry
[:add-existing-values-to-closed-values [property-id values]]
(fn []
(when-let [property (d/entity @conn property-id)]
(when (seq values)
(let [values' (remove string/blank? values)]
(assert (every? uuid? values') "existing values should all be UUIDs")
(let [values (keep #(d/entity @conn [:block/uuid %]) values')]
(when (seq values)
(let [value-property-tx (map (fn [id]
{:db/id id
:block/closed-value-property (:db/id property)})
(map :db/id values))
property-tx (outliner-core/block-with-updated-at {:db/id (:db/id property)})]
(transact-with-op! conn (cons property-tx value-property-tx)
{:outliner-op :save-blocks}))))))))))
(defn delete-closed-value!
"Returns true when deleted or if not deleted displays warning and returns false"
[conn property-id value-block-id]
(when (or (nil? property-id)
(nil? value-block-id))
(throw (ex-info "empty property-id or value-block-id when delete-closed-value!"
{:property-id property-id
:value-block-id value-block-id})))
(when-let [value-block (d/entity @conn value-block-id)]
(if (ldb/built-in? value-block)
(throw (ex-info "The choice can't be deleted"
{:type :notification
:payload {:message "The choice can't be deleted because it's built-in."
:type :warning}}))
(let [tx-data (conj (:tx-data (outliner-core/delete-blocks @conn [value-block] {:hard-retract? true}))
(outliner-core/block-with-updated-at {:db/id property-id}))]
(ldb/transact! conn tx-data)))))
(with-op-entry
[:delete-closed-value [property-id value-block-id]]
(fn []
(when (or (nil? property-id)
(nil? value-block-id))
(throw (ex-info "empty property-id or value-block-id when delete-closed-value!"
{:property-id property-id
:value-block-id value-block-id})))
(when-let [value-block (d/entity @conn value-block-id)]
(if (ldb/built-in? value-block)
(throw (ex-info "The choice can't be deleted"
{:type :notification
:payload {:message "The choice can't be deleted because it's built-in."
:type :warning}}))
(let [tx-data (conj (:tx-data (outliner-core/delete-blocks @conn [value-block] {}))
(outliner-core/block-with-updated-at {:db/id property-id}))]
(transact-with-op! conn tx-data {})))))))
(defn class-add-property!
[conn class-id property-id]
(when-not (contains? #{:logseq.property/empty-placeholder} property-id)
(when-let [class (d/entity @conn class-id)]
(if (ldb/class? class)
(ldb/transact! conn
[[:db/add (:db/id class) :logseq.property.class/properties property-id]]
{:outliner-op :save-block})
(throw (ex-info "Can't add a property to a block that isn't a class"
{:class-id class-id :property-id property-id}))))))
(with-op-entry
[:class-add-property [class-id property-id]]
(fn []
(when-not (contains? #{:logseq.property/empty-placeholder} property-id)
(when-let [class (d/entity @conn class-id)]
(if (ldb/class? class)
(transact-with-op! conn
[[:db/add (:db/id class) :logseq.property.class/properties property-id]]
{:outliner-op :save-block})
(throw (ex-info "Can't add a property to a block that isn't a class"
{:class-id class-id :property-id property-id}))))))))
(defn class-remove-property!
[conn class-id property-id]
(when-let [class (d/entity @conn class-id)]
(when (ldb/class? class)
(when-let [property (d/entity @conn property-id)]
(when-not (ldb/built-in-class-property? class property)
(ldb/transact! conn [[:db/retract (:db/id class) :logseq.property.class/properties property-id]]
{:outliner-op :save-block}))))))
(with-op-entry
[:class-remove-property [class-id property-id]]
(fn []
(when-let [class (d/entity @conn class-id)]
(when (ldb/class? class)
(when-let [property (d/entity @conn property-id)]
(when-not (ldb/built-in-class-property? class property)
(transact-with-op! conn
[[:db/retract (:db/id class) :logseq.property.class/properties property-id]]
{:outliner-op :save-block}))))))))

View File

@@ -8,7 +8,7 @@
[logseq.db.common.order :as db-order]))
(def ^:private recycle-page-title "Recycle")
(def ^:private retention-ms (* 60 24 3600 1000))
(def ^:private retention-ms (* 30 24 3600 1000))
(def gc-interval-ms (* 24 3600 1000))
(defn- recycled?

View File

@@ -3,17 +3,17 @@
transient state from logseq.outliner.core"
#?(:cljs (:require-macros [logseq.outliner.transaction])))
(defmacro ^:api with-temp-conn-batch
(defmacro ^:api with-batch-tx
[conn opts & body]
(let [temp-conn-sym (gensym "temp-conn__")]
`(logseq.db/transact-with-temp-conn!
(let [conn-sym (gensym "conn__")]
`(logseq.db/batch-transact-with-temp-conn!
~conn
(dissoc ~opts :additional-tx :transact-opts :current-block)
(fn [~temp-conn-sym _*batch-tx-data#]
(let [~conn ~temp-conn-sym]
(fn [~conn-sym]
(let [~conn ~conn-sym]
~@body
(when (seq (:additional-tx ~opts))
(logseq.db/transact! ~temp-conn-sym (:additional-tx ~opts) {})))))))
(logseq.db/transact! ~conn-sym (:additional-tx ~opts) {})))))))
(defmacro ^:api transact!
"Batch all the transactions in `body` to a single transaction.
@@ -33,7 +33,7 @@
(delete-blocks! ...))"
[opts & body]
`(let [~'conn (:conn (:transact-opts ~opts))]
(logseq.outliner.transaction/with-temp-conn-batch
(logseq.outliner.transaction/with-batch-tx
~'conn
~opts
~@body)))

View File

@@ -0,0 +1,11 @@
(ns logseq.outliner.tx-meta
"Helpers for normalizing tx metadata with explicit outliner op entries.")
(def ^:dynamic *outliner-op-entry* nil)
(defn ensure-outliner-ops
[tx-meta fallback-op-entry]
(let [entry (or *outliner-op-entry* fallback-op-entry)]
(cond-> (or tx-meta {})
(and entry (nil? (:outliner-ops tx-meta)))
(assoc :outliner-ops [entry]))))

View File

@@ -2,39 +2,30 @@
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]))
(deftest test-delete-block-with-default-property
(testing "Delete block with default property moves the block to recycle"
(testing "Delete block with default property hard retracts the block subtree"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "test block"}}]}])
block (db-test/find-block-by-content @conn "b1")]
(outliner-core/delete-blocks! conn [block] {})
(let [block' (db-test/find-block-by-content @conn "b1")
property-value (:user.property/default block')
recycle-page (ldb/get-built-in-page @conn "Recycle")]
(is (some? block'))
(is (some? property-value))
(is (integer? (:logseq.property/deleted-at block')))
(is (= (:db/id recycle-page) (:db/id (:block/page block'))))
(is (= (:db/id recycle-page) (:db/id (:block/page property-value))))))))
(is (nil? (db-test/find-block-by-content @conn "b1"))))))
(deftest test-delete-page-with-outliner-core
(testing "Pages shouldn't be deleted through outliner-core/delete-blocks"
(testing "Deleting pages through outliner-core/delete-blocks detaches page position only"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}
{:page {:block/title "page2"}
:blocks [{:block/title "b3"}
{:block/title "b4"}]}])
page1 (ldb/get-page @conn "page1")
page2 (ldb/get-page @conn "page2")
_ (d/transact! conn [{:db/id (:db/id page2)
:block/order "a1"
:block/parent (:db/id page1)}])
:block/parent (:db/id (ldb/get-page @conn "page1"))}])
b3 (db-test/find-block-by-content @conn "b3")
b4 (db-test/find-block-by-content @conn "b4")]
(outliner-core/delete-blocks! conn [b3 b4 page2] {})
@@ -42,35 +33,20 @@
(is (some? (db-test/find-block-by-content @conn "b4")))
(let [page2' (ldb/get-page @conn "page2")]
(is (= "page2" (:block/title page2')))
(is (= (:db/id page1) (:db/id (:block/parent page2'))))
(is (= "a1" (:block/order page2')))))))
(is (nil? (:block/parent page2')))
(is (nil? (:block/order page2')))))))
(deftest delete-blocks-moves-subtree-to-recycle
(deftest delete-blocks-hard-retracts-subtree
(let [user-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
page (ldb/get-page @conn "page1")
parent (db-test/find-block-by-content @conn "parent")
original-order (:block/order parent)]
parent (db-test/find-block-by-content @conn "parent")]
(d/transact! conn [{:block/uuid user-uuid
:block/title "Alice"}])
(outliner-core/delete-blocks! conn [parent] {:deleted-by-uuid user-uuid})
(let [parent' (db-test/find-block-by-content @conn "parent")
child' (db-test/find-block-by-content @conn "child")
properties (entity-plus/lookup-kv-then-entity parent' :block/properties)
recycle-page (ldb/get-built-in-page @conn "Recycle")]
(is (some? parent'))
(is (some? child'))
(is (= (:block/uuid recycle-page) (:block/uuid (:block/parent parent'))))
(is (= (:block/uuid recycle-page) (:block/uuid (:block/page parent'))))
(is (integer? (:logseq.property/deleted-at parent')))
(is (= user-uuid
(:block/uuid (:logseq.property/deleted-by-ref properties))))
(is (= (:block/uuid page)
(:block/uuid (:logseq.property.recycle/original-page properties))))
(is (= original-order (:logseq.property.recycle/original-order parent')))
(is (= (:block/uuid parent') (:block/uuid (:block/parent child'))))
(is (= (:block/uuid recycle-page) (:block/uuid (:block/page child'))))
(is (nil? (:logseq.property/deleted-at child'))))))
child' (db-test/find-block-by-content @conn "child")]
(is (nil? parent'))
(is (nil? child')))))

View File

@@ -0,0 +1,404 @@
(ns logseq.outliner.op-construct-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.op.construct :as op-construct]))
(defn- run-direct-outdent
[conn block]
(let [{:keys [tx-data]}
(#'outliner-core/indent-outdent-blocks
conn [block] false
:parent-original nil
:logical-outdenting? nil)
tx-report (d/with @conn tx-data {})]
{:tx-data (:tx-data tx-report)
:db-after (:db-after tx-report)}))
(deftest derive-history-outliner-ops-canonicalizes-create-page-and-builds-delete-inverse-test
(testing "create-page forward op keeps created uuid and reverse op deletes that page"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
page-uuid (random-uuid)
tx-data [{:e 1 :a :block/title :v "Created Page" :added true}
{:e 1 :a :block/uuid :v page-uuid :added true}]
tx-meta {:outliner-op :create-page
:outliner-ops [[:create-page ["Created Page"
{:redirect? false
:split-namespace? true
:tags ()}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= :create-page (ffirst forward-outliner-ops)))
(is (= page-uuid (get-in forward-outliner-ops [0 1 1 :uuid])))
(is (= [[:delete-page [page-uuid {}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-collapses-mixed-stream-to-transact-placeholder-test
(testing "mixed semantic/non-semantic ops collapse to transact placeholder"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "child"}]}]})
child (db-test/find-block-by-content @conn "child")
tx-meta {:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid (:block/uuid child)
:block/title "changed"} {}]]
[:transact nil]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [[:transact nil]] forward-outliner-ops))
(is (nil? inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-handles-replace-empty-target-insert-inverse-test
(testing "replace-empty-target insert keeps source uuid and inverse deletes target placeholder"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title ""}]}]})
empty-target (db-test/find-block-by-content @conn "")
parent-uuid (random-uuid)
child-uuid (random-uuid)
tx-meta {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid parent-uuid
:block/title "paste parent"}
{:block/uuid child-uuid
:block/title "paste child"
:block/parent [:block/uuid parent-uuid]}]
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:outliner-op :paste}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= parent-uuid
(get-in forward-outliner-ops [0 1 0 0 :block/uuid])))
(is (= true (get-in forward-outliner-ops [0 1 2 :keep-uuid?])))
(is (some #(and (= :delete-blocks (first %))
(= [[:block/uuid (:block/uuid empty-target)]]
(vec (get-in % [1 0]))))
(remove nil? inverse-outliner-ops)))
(is (not-any? #(= :save-block (first %))
(remove nil? inverse-outliner-ops))))))
(deftest derive-history-outliner-ops-builds-upsert-property-inverse-delete-page-test
(testing "upsert-property with qualified keyword builds delete-page inverse"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
property-id :user.property/custom-prop
tx-meta {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-id
{:logseq.property/type :default}
{:property-name "custom-prop"}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)
expected-page-uuid (common-uuid/gen-uuid :db-ident-block-uuid property-id)]
(is (= [[:delete-page [expected-page-uuid {}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-delete-blocks-inverse-avoids-self-target-test
(testing "delete-blocks inverse falls back to parent target when left sibling resolves to self"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-id (:db/id child)
child-uuid (:block/uuid child)
parent-uuid (some-> child :block/parent :block/uuid)
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[child-id] {}]]]}]
;; Simulate stale sibling lookup returning the same entity as the deleted root.
(with-redefs [ldb/get-left-sibling (fn [_] child)]
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)
insert-op (first inverse-outliner-ops)]
(is (= :insert-blocks (first insert-op)))
(is (= [:block/uuid parent-uuid]
(get-in insert-op [1 1])))
(is (= false (get-in insert-op [1 2 :sibling?])))
(is (not= [:block/uuid child-uuid]
(get-in insert-op [1 1]))))))))
(deftest derive-history-outliner-ops-builds-delete-page-inverse-for-class-property-and-today-page-test
(testing "delete-page inverse restores hard-retracted class/property/today pages with stable db/ident"
(let [today (date-time-util/ms->journal-day (js/Date.))
conn (db-test/create-conn-with-blocks
{:classes {:Movie {}}
:properties {:rating {:logseq.property/type :number}}
:pages-and-blocks [{:page {:build/journal today}
:blocks [{:block/title "today child"}]}]})
class-page (ldb/get-page @conn "Movie")
property-page (d/entity @conn :user.property/rating)
today-page (db-test/find-journal-by-journal-day @conn today)
today-child (db-test/find-block-by-content @conn "today child")
class-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid class-page) {}]]]}))
property-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid property-page) {}]]]}))
today-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid today-page) {}]]]}))]
(is (some #(= :create-page (first %)) class-inverse))
(is (some #(= :save-block (first %)) class-inverse))
(is (= (:db/ident class-page)
(get-in (some #(when (= :save-block (first %)) %) class-inverse)
[1 0 :db/ident])))
(is (some #(= :upsert-property (first %)) property-inverse))
(is (some #(= :save-block (first %)) property-inverse))
(is (= (:db/ident property-page)
(get-in (some #(when (= :save-block (first %)) %) property-inverse)
[1 0 :db/ident])))
(is (not-any? #(= :restore-recycled (first %)) today-inverse))
(let [today-insert-op (some #(when (= :insert-blocks (first %)) %) today-inverse)]
(is (some? today-insert-op))
(is (= (:block/uuid today-page)
(second (get-in today-insert-op [1 1]))))
(is (= (:block/uuid today-child)
(get-in today-insert-op [1 0 0 :block/uuid])))))))
(deftest derive-history-outliner-ops-builds-inverse-for-all-supported-ops-test
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1]}}
:properties {:p1 {:logseq.property/type :default}}
:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-a"}
{:block/title "child-b"}]}
{:block/title "prop-block-1"
:build/properties {:p1 "before-1"}}
{:block/title "prop-block-2"}]}]})
page (db-test/find-page-by-title @conn "page")
parent (db-test/find-block-by-content @conn "parent")
child-a (db-test/find-block-by-content @conn "child-a")
child-b (db-test/find-block-by-content @conn "child-b")
prop-block-1 (db-test/find-block-by-content @conn "prop-block-1")
prop-block-2 (db-test/find-block-by-content @conn "prop-block-2")
class-id (:db/id (d/entity @conn :user.class/c1))
class-uuid (:block/uuid (d/entity @conn class-id))
property-id (:db/id (d/entity @conn :user.property/p1))
property-page-uuid (:block/uuid (d/entity @conn property-id))
prop-value-1-id (:db/id (:user.property/p1 prop-block-1))]
(testing ":save-block"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid (:block/uuid child-a)
:block/title "changed"} {}]]]})]
(is (= :save-block (ffirst inverse-outliner-ops)))
(is (= (:block/uuid child-a)
(get-in inverse-outliner-ops [0 1 0 :block/uuid])))))
(testing ":insert-blocks"
(let [inserted-uuid (random-uuid)
tx-data [{:e 999999 :a :block/uuid :v inserted-uuid :added true}]
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn tx-data {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid inserted-uuid
:block/title "new"}]
(:db/id parent)
{:sibling? false}]]]})]
(is (= :delete-blocks (ffirst inverse-outliner-ops)))
(is (= [[:block/uuid inserted-uuid]]
(vec (get-in inverse-outliner-ops [0 1 0]))))))
(testing ":move-blocks"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :move-blocks
:outliner-ops [[:move-blocks [[(:db/id child-b)]
(:db/id parent)
{:sibling? false}]]]})]
(is (= :move-blocks (ffirst inverse-outliner-ops)))
(is (= [[:block/uuid (:block/uuid child-b)]]
(get-in inverse-outliner-ops [0 1 0])))))
(testing ":delete-blocks"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[(:db/id child-b)] {}]]]})]
(is (= :insert-blocks (ffirst inverse-outliner-ops)))
(is (= (:block/uuid child-b)
(get-in inverse-outliner-ops [0 1 0 0 :block/uuid])))))
(testing ":create-page"
(let [page-uuid (random-uuid)
tx-data [{:e 1 :a :block/title :v "P2" :added true}
{:e 1 :a :block/uuid :v page-uuid :added true}]
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn tx-data {:outliner-op :create-page
:outliner-ops [[:create-page ["P2" {:redirect? false}]]]})]
(is (= [[:delete-page [page-uuid {}]]]
inverse-outliner-ops))))
(testing ":delete-page"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid page) {}]]]})]
(is (= [[:restore-recycled [(:block/uuid page)]]]
inverse-outliner-ops))))
(testing ":set-block-property"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :set-block-property
:outliner-ops [[:set-block-property [(:db/id prop-block-1)
:user.property/p1
"new-value"]]]})]
(is (= :set-block-property (ffirst inverse-outliner-ops)))
(is (= [:block/uuid (:block/uuid prop-block-1)]
(get-in inverse-outliner-ops [0 1 0])))
(is (= :user.property/p1 (get-in inverse-outliner-ops [0 1 1])))
(is (= prop-value-1-id (get-in inverse-outliner-ops [0 1 2 :db/id])))))
(testing ":remove-block-property"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :remove-block-property
:outliner-ops [[:remove-block-property [(:db/id prop-block-1)
:user.property/p1]]]})]
(is (= :set-block-property (ffirst inverse-outliner-ops)))
(is (= [:block/uuid (:block/uuid prop-block-1)]
(get-in inverse-outliner-ops [0 1 0])))
(is (= :user.property/p1 (get-in inverse-outliner-ops [0 1 1])))
(is (= prop-value-1-id (get-in inverse-outliner-ops [0 1 2 :db/id])))))
(testing ":batch-set-property"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :batch-set-property
:outliner-ops [[:batch-set-property [[(:db/id prop-block-1)
(:db/id prop-block-2)]
:user.property/p1
"new-value"
{}]]]})]
(is (= 2 (count inverse-outliner-ops)))
(is (= :set-block-property (ffirst inverse-outliner-ops)))
(is (= :remove-block-property (ffirst (rest inverse-outliner-ops))))))
(testing ":batch-remove-property"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :batch-remove-property
:outliner-ops [[:batch-remove-property [[(:db/id prop-block-1)
(:db/id prop-block-2)]
:user.property/p1]]]})]
(is (= 1 (count inverse-outliner-ops)))
(is (= :set-block-property (ffirst inverse-outliner-ops)))
(is (= [:block/uuid (:block/uuid prop-block-1)]
(get-in inverse-outliner-ops [0 1 0])))
(is (= :user.property/p1 (get-in inverse-outliner-ops [0 1 1])))
(is (= prop-value-1-id (get-in inverse-outliner-ops [0 1 2 :db/id])))))
(testing ":class-add-property"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :class-add-property
:outliner-ops [[:class-add-property [class-id property-id]]]})]
(is (= [[:class-remove-property [[:block/uuid class-uuid]
[:block/uuid property-page-uuid]]]]
inverse-outliner-ops))))
(testing ":class-remove-property"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :class-remove-property
:outliner-ops [[:class-remove-property [class-id property-id]]]})]
(is (= [[:class-add-property [[:block/uuid class-uuid]
[:block/uuid property-page-uuid]]]]
inverse-outliner-ops))))
(testing ":upsert-property"
(let [property-ident :user.property/test-inverse
expected-page-uuid (common-uuid/gen-uuid :db-ident-block-uuid property-ident)
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-ident
{:logseq.property/type :default}
{:property-name "test-inverse"}]]]})]
(is (= [[:delete-page [expected-page-uuid {}]]]
inverse-outliner-ops))))))
(deftest build-history-action-metadata-direct-outdent-builds-indent-outdent-forward-and-inverse-test
(testing "direct outdent keeps canonical indent-outdent forward and inverse ops"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-1"}
{:block/title "child-2"}
{:block/title "child-3"}]}]}]})
child-3 (db-test/find-block-by-content @conn "child-3")
{:keys [tx-data db-after]} (run-direct-outdent conn child-3)
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:indent-outdent-blocks [[(:db/id child-3)]
false
{:parent-original nil
:logical-outdenting? nil}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn db-after tx-data tx-meta)]
(is (= [[:indent-outdent-blocks [[[:block/uuid (:block/uuid child-3)]]
false
{:parent-original nil
:logical-outdenting? nil}]]]
forward-outliner-ops))
(is (= [[:indent-outdent-blocks [[[:block/uuid (:block/uuid child-3)]]
true
{:parent-original nil
:logical-outdenting? nil}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-direct-outdent-with-extra-moved-blocks-keeps-semantic-ops-test
(testing "direct outdent keeps semantic indent-outdent op and inverse"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-1"}
{:block/title "child-2"}
{:block/title "child-3"}]}]}]})
child-2 (db-test/find-block-by-content @conn "child-2")
{:keys [tx-data db-after]} (run-direct-outdent conn child-2)
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:indent-outdent-blocks [[(:db/id child-2)]
false
{:parent-original nil
:logical-outdenting? nil}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn db-after tx-data tx-meta)]
(is (= [[:indent-outdent-blocks [[[:block/uuid (:block/uuid child-2)]]
false
{:parent-original nil
:logical-outdenting? nil}]]]
forward-outliner-ops))
(is (= [[:indent-outdent-blocks [[[:block/uuid (:block/uuid child-2)]]
true
{:parent-original nil
:logical-outdenting? nil}]]]
inverse-outliner-ops)))))
(deftest build-history-action-metadata-non-semantic-outliner-op-does-not-throw-test
(testing "non-semantic outliner-op with transact placeholder should not fail strict semantic validation"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
tx-meta {:outliner-op :restore-recycled
:outliner-ops [[:transact nil]]}
result (op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [[:transact nil]]
(:forward-outliner-ops result)))
(is (nil? (:inverse-outliner-ops result))))))

View File

@@ -116,6 +116,9 @@
(is (some? b1'))
(is (= (:block/uuid recycle-page) (:block/uuid (:block/parent d1'))))
(is (integer? (:logseq.property/deleted-at d1')))
(is (nil? (:block/raw-title b1')))
(is (contains? (set (map :db/id (:block/refs b1')))
(:db/id d1)))
(is (= (:block/uuid d1') (:block/uuid (:block/page b1')))))))
(deftest delete-class-page-hard-retracts-page-tree

View File

@@ -1,50 +1,9 @@
(ns logseq.outliner.recycle-test
(:require [cljs.test :refer [deftest is]]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.recycle :as recycle]))
(deftest restore-recycled-block-returns-subtree-to-original-location
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}
{:block/title "sibling"}]}])
page (ldb/get-page @conn "page1")
parent (db-test/find-block-by-content @conn "parent")]
(outliner-core/delete-blocks! conn [parent] {})
(recycle/restore! conn (:block/uuid parent))
(let [parent' (db-test/find-block-by-content @conn "parent")
child' (db-test/find-block-by-content @conn "child")]
(is (= (:block/uuid page) (:block/uuid (:block/parent parent'))))
(is (= (:block/uuid page) (:block/uuid (:block/page parent'))))
(is (= (:block/uuid parent') (:block/uuid (:block/parent child'))))
(is (= (:block/uuid page) (:block/uuid (:block/page child'))))
(is (nil? (:logseq.property/deleted-at parent')))
(is (nil? (:logseq.property/deleted-by-ref parent')))
(is (nil? (:logseq.property.recycle/original-parent parent')))
(is (nil? (:logseq.property.recycle/original-page parent')))
(is (nil? (:logseq.property.recycle/original-order parent'))))))
(deftest restore-recycled-block-falls-back-to-page-root-when-original-parent-is-unavailable
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}
{:block/title "sibling"}]}])
page (ldb/get-page @conn "page1")
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")]
(outliner-core/delete-blocks! conn [child] {})
(outliner-core/delete-blocks! conn [parent] {})
(recycle/restore! conn (:block/uuid child))
(let [child' (db-test/find-block-by-content @conn "child")]
(is (= (:block/uuid page) (:block/uuid (:block/parent child'))))
(is (= (:block/uuid page) (:block/uuid (:block/page child'))))
(is (nil? (:logseq.property/deleted-at child'))))))
(deftest restore-recycled-page-removes-recycle-parent
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
@@ -57,19 +16,3 @@
(is (nil? (:block/parent page')))
(is (nil? (:logseq.property/deleted-at page')))
(is (nil? (:logseq.property.recycle/original-parent page'))))))
(deftest gc-retracts-recycled-subtrees-older-than-retention-window
(let [now-ms 1000
old-ms (- now-ms (* 61 24 3600 1000))
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")]
(outliner-core/delete-blocks! conn [parent] {})
(d/transact! conn [{:db/id (:db/id (db-test/find-block-by-content @conn "parent"))
:logseq.property/deleted-at old-ms}])
(recycle/gc! conn {:now-ms now-ms})
(is (nil? (d/entity @conn [:block/uuid (:block/uuid parent)])))
(is (nil? (d/entity @conn [:block/uuid (:block/uuid child)])))))

View File

@@ -0,0 +1,147 @@
# ADR 0010: Canonical Op-Driven Client Rebase With Legacy Tx Surgery Isolation
Date: 2026-03-19
Status: Accepted
## Context
Client sync rebase currently relies on custom tx-data surgery to keep pending
local changes alive after remote txs are applied.
That approach has several problems:
- the logic is hard to reason about because it edits datoms instead of replaying
user intent
- it does not preserve intent well when a local change originated from a higher
level outliner action
- the helper set keeps growing with special cases for missing refs, structural
conflicts, and deleted entities
- the outliner-op surface is too large to replay directly without first
reducing it
At the same time, not every outliner op needs to remain a first-class replay
operation.
Many ops can be safely represented by `:transact` when their tx-data is already
self-contained and does not depend on rerunning outliner logic.
We also have existing persisted pending rows that only store tx-data and reverse
tx-data.
Those rows still need a compatibility path, but that path should not define the
new rebase architecture.
## Decision
1. New pending local tx rows will persist semantic `:outliner-ops` in addition
to their existing tx-data payload.
2. Client rebase will become op-driven for all new pending rows:
- reverse local txs
- apply remote txs
- transform or drop stored local ops against the post-remote temp db
- replay the surviving ops to regenerate rebased tx-data
3. Reduce the replay-visible outliner-op surface to a small canonical set:
- `:insert-blocks`
- `:save-block`
- `:move-blocks`
- `:delete-blocks`
- `:transact`
4. Normalize higher-level ops into that canonical set before persistence.
Examples:
- `:indent-outdent-blocks` becomes `:move-blocks`
- `:move-blocks-up-down` becomes `:move-blocks`
- `:rename-page` becomes `:save-block`
5. Introduce an explicit safe-`:transact` classifier:
- if replaying tx-data directly is sufficient and does not require rerunning
outliner logic, persist the op as canonical `:transact`
- otherwise keep it as one of the canonical outliner replay ops
- expected canonical `:transact` cases include direct tx replay actions such
as undo, redo, import replay, reaction toggles, and property-value updates
6. Treat undo/redo as canonical `:transact` actions.
- persist the exact tx-data that the successful undo or redo applied
- keep the action atomic as one pending tx row
- do not reconstruct the original higher-level outliner ops that were
undone or redone
7. Treat `:batch-import-edn` as canonical `:transact` after successful local
execution.
- persist the exact tx-data that the import applied
- do not rerun import expansion during rebase
8. Keep the following op kinds as replay-visible semantic ops because they must
be reevaluated against current DB state:
- `:save-block`
- `:insert-blocks`
- `:move-blocks`
- `:delete-blocks`
- `:create-page`
- `:delete-page`
- `:upsert-property`
9. Stop using raw tx-data surgery in the normal rebase path for new rows.
10. Move the current tx-data surgery helpers into a dedicated legacy namespace.
That legacy namespace is only for compatibility handling of old persisted
pending rows that do not have stored `:outliner-ops`.
11. Add explicit owned-block filtering in the new op-driven rebase path.
Initially cover:
- reaction blocks owned by `:logseq.property.reaction/target`
- property history blocks owned by `:logseq.property.history/block`
- property history blocks whose effective owner disappears through deleted
`:logseq.property.history/ref-value`
12. If a local op creates or updates one of those owned blocks and the owning
block was deleted remotely, drop that op.
13. Treat each pending tx row as one user action and keep it atomic during
rebase.
14. If any op in a pending tx becomes invalid during rebase, drop the whole
pending tx rather than keeping a partial replay of that user action.
15. Keep this refactor client-only for now.
The sync wire format and server tx log remain unchanged.
## Consequences
- Positive:
- Rebase reasons about user intent instead of datom accidents.
- The number of op kinds that rebase must understand becomes much smaller.
- Safe direct tx replay remains available through canonical `:transact`
without forcing every operation through outliner code.
- Undo/redo stays aligned with its real intent: replay the exact applied tx,
not rerun a reconstructed higher-level command.
- Import replay stays aligned with its real intent: preserve the exact local
import result rather than recomputing import expansion later.
- Legacy compatibility is isolated instead of contaminating the new design.
- Owned-block cleanup becomes an explicit semantic rule rather than another
tx-data patch.
- Rebase behavior stays aligned with the mental model that one pending tx is
one user action that either survives or is discarded as a whole.
- Negative:
- We must maintain a canonicalization layer from original outliner ops to the
reduced replay set.
- Some existing ops need careful classification to decide whether they are
safe `:transact` or must stay true outliner replays.
- For a transition period, the codebase will contain both the new rebase path
and the isolated legacy compatibility path.
- If one invalid op is grouped together with otherwise valid work in the same
pending tx, the whole user action will be lost during rebase.
## Follow-up Constraints
- New pending tx producers must persist canonical `:outliner-ops`.
- New pending tx producers must preserve user-action boundaries because rebase
will treat each persisted tx row atomically.
- Canonicalization should happen when persisting local pending txs, not lazily
during rebase.
- Undo/redo producers should persist canonical `:transact` actions using the
exact tx-data they applied.
- Import producers should persist canonical `:transact` actions using the exact
tx-data they applied.
- The main sync apply namespace should not call legacy tx-surgery helpers for
new pending rows.
- The legacy namespace should be clearly named and easy to delete once old
pending-row compatibility is no longer needed.
## Verification
- Add or update frontend worker db-sync coverage for:
- persistence of canonical `:outliner-ops`
- canonical reduction of `:indent-outdent-blocks` and
`:move-blocks-up-down` into `:move-blocks`
- safe `:transact` replay versus true outliner replay classification
- undo/redo persistence as atomic canonical `:transact` actions
- `:batch-import-edn` persistence as atomic canonical `:transact`
- `:rename-page` canonicalization to `:save-block`
- op-driven rebase preserving pending tx boundaries
- dropping owned reaction/history ops when their owner was deleted remotely
- dropping the whole pending tx when any op in that user action becomes
invalid
- routing legacy pending rows without stored ops through the legacy namespace
- Expected targeted command:
- `bb dev:test -v frontend.worker.db-sync-test`

View File

@@ -0,0 +1,378 @@
# ADR 0011: Undo/Redo Semantic Inverse-Op Persistence and Local-Op Referenced UI History
Date: 2026-03-21
Status: Proposed
## Context
ADR 0010 deliberately classified undo and redo as canonical `:transact` actions.
That choice kept pending-row persistence simple, but it also preserved undo and redo as raw datom replay rather than preserving their intent.
That tradeoff is now the main weakness in the sync rebase model.
During client rebase, pending local actions are reversed, remote txs are applied, and then pending local actions are replayed.
That architecture works well when the persisted action is a semantic op such as `:save-block`, `:insert-blocks`, or `:move-blocks`.
It works poorly when the persisted action is a raw undo or redo tx payload because the replay logic can no longer distinguish commutative remote updates from true semantic conflicts.
The current undo/redo design also duplicates DB history across two places.
The main thread owns the undo and redo stacks because it stores UI state such as route state, editor cursor, and focus context.
At the same time, the worker already persists local DB actions in client-ops storage with a stable `tx-id`.
Those two histories are related but not unified.
As a result, sync rebase, undo/redo, and pending local action persistence can drift apart.
The user action identity is already represented in the worker by `:db-sync/tx-id`, but the UI undo stack still stores the DB tx payload itself instead of referencing that persisted action.
This ADR defines a plan to make the client-op row the source of truth for DB history while keeping the main thread as the source of truth for UI state.
Constraints:
- The main thread must continue to own UI-only undo state.
- The sync wire protocol should remain unchanged.
- Existing persisted pending rows without semantic metadata are intentionally out of scope for this change.
- Rebase should continue to preserve one logical pending row as one user action.
- The design must preserve redo, not just undo.
Related ADRs:
- Builds on ADR 0002.
- Builds on ADR 0010.
- Supersedes ADR 0010 decision 6, which treated undo/redo as canonical `:transact`.
## Problem Summary
The current model has three tightly coupled problems.
First, undo and redo are persisted as raw tx replay.
That makes rebase unable to reason about their semantics.
Second, the undo stack on the main thread stores DB payloads directly instead of referencing the persisted local action row in worker storage.
That means the UI history and worker history are not guaranteed to stay aligned.
Third, the current worker pending-row schema persists forward tx data and reverse tx data, but it does not persist both semantic forward ops and semantic inverse ops as first-class action metadata.
That prevents robust rebase and robust redo from using the same action identity.
The result is that a benign remote update such as a title edit can still force undo/redo to fall back to raw replay, which is unnecessarily lossy and much harder to classify correctly.
## Decision
1. The worker client-op row identified by `:db-sync/tx-id` becomes the source of truth for DB undo/redo history.
2. The main-thread undo/redo stacks will store UI state plus a reference to that worker action row, not the DB tx payload itself.
3. New client-op rows will persist both forward semantic outliner ops and inverse semantic outliner ops.
4. Redo will replay the persisted forward semantic ops for the referenced action row.
5. Undo will replay the persisted inverse semantic ops for the referenced action row.
6. New rebase logic will use semantic forward or inverse ops for new undo/redo-backed action rows instead of raw datom replay.
7. Existing `normalized-tx-data` and `reversed-tx-data` fields remain temporarily for debugging, validation, and controlled cleanup, but they will not define replay behavior for the new undo/redo architecture.
8. The logical `tx-id` remains stable across rebase rewrites so that main-thread stack entries do not break when the worker rewrites the row contents.
9. Undo/redo rows that cannot be represented with semantic inverse ops are unsupported until a semantic representation is added explicitly.
## Target Architecture
The architecture separates UI history from DB history while keeping both linked through one stable action identifier.
```text
+-------------------+ tx-id +----------------------------+
| Main thread | -----------------------> | Worker client-ops storage |
| undo/redo stack | | one row per logical action |
| | <----------------------- | stable tx-id |
| UI state | action result | forward semantic ops |
| editor cursor | | inverse semantic ops |
| route/sidebar | | normalized tx data |
+-------------------+ | reverse tx data (legacy) |
+----------------------------+
|
v
+----------------------------+
| Sync rebase + upload |
| replay by semantic ops |
+----------------------------+
```
The main thread continues to own UI-only history because the worker should not become responsible for route or editor selection state.
The worker becomes responsible for DB action identity and replay semantics because that is the same place that already owns pending local action persistence, sync upload, and rebase.
## Data Model Changes
The client-op row needs to distinguish between the identity of a logical action and the concrete tx-data that happened to be generated on the current device revision.
The row should keep the existing logical `tx-id`.
The row should add explicit semantic operation fields for both directions.
Recommended schema additions:
| Field | Purpose |
| --- | --- |
| `:db-sync/forward-outliner-ops` | Canonical semantic ops that reapply the action. |
| `:db-sync/inverse-outliner-ops` | Canonical semantic ops that undo the action. |
| `:db-sync/history-kind` | Distinguishes regular action rows from specialized worker history rows if needed. |
| `:db-sync/source-tx-id` | Optional link from an undo/redo execution row back to the original action row during migration. |
| `:db-sync/semantic-persistence-version` | Marks rows that are safe for semantic replay. |
The existing fields should remain during migration:
| Existing field | Migration role |
| --- | --- |
| `:db-sync/normalized-tx-data` | Validation reference and emergency cleanup aid. |
| `:db-sync/reversed-tx-data` | Local safety checks and controlled cleanup aid. |
| `:db-sync/outliner-op` | Existing summary/debug field. |
| `:db-sync/outliner-ops` | Can be folded into `forward-outliner-ops` once migration is complete. |
The main-thread undo stack entry shape should change accordingly.
The DB portion of a stack entry should become a small reference object instead of an embedded tx payload.
Recommended main-thread DB history payload:
| Field | Purpose |
| --- | --- |
| `:local-op-tx-id` | Stable reference to worker client-op row. |
| `:history-direction` | `:forward` in undo stack and `:inverse` in redo stack if needed for UI bookkeeping. |
| `:history-version` | Allows invalidating stale stack entries across releases. |
The main-thread stack entry should continue to store editor cursor and UI route state as it does today.
## Semantic Persistence Rules
The plan depends on persisting semantic inverse ops instead of reconstructing them later from raw datoms.
That means the inverse ops must be created at the time a local action is first recorded into undo history.
The worker should never have to guess the inverse of an already-lost user intent from only tx-data if the action was produced by a known canonical outliner op.
The persistence rules are:
1. A regular local action row persists canonical forward ops.
2. The undo stack entry points at that action row by `tx-id`.
3. Undo execution uses the inverse semantic ops stored on the referenced row.
4. Redo execution uses the forward semantic ops stored on the referenced row.
5. If a row does not have semantic inverse ops, it must not enter the semantic rebase path.
## Canonical Op Surface
This ADR does not require every outliner op to become replay-visible immediately.
It does require the canonical replay-visible surface for undo/redo to be explicit and versioned.
Recommended canonical surface for forward and inverse persistence:
- `:save-block`
- `:insert-blocks`
- `:move-blocks`
- `:delete-blocks`
- `:set-block-property`
- `:remove-block-property`
- `:batch-set-property`
- `:batch-remove-property`
- `:delete-property-value`
- `:batch-delete-property-value`
- `:create-property-text-block`
- `:upsert-closed-value`
- `:delete-closed-value`
- `:add-existing-values-to-closed-values`
- `:create-page`
- `:delete-page`
- `:rename-page`
If an action cannot be expressed in that surface with a safe inverse, it should remain unsupported until a semantic representation is added deliberately.
That is preferable to reclassifying it as safe raw replay by accident.
## Inverse Op Generation Strategy
Inverse semantic ops should be created from the original action metadata and the pre-action DB state, not by reverse-engineering datoms after the fact.
The current main-thread undo history already retains the original tx meta, which includes `:outliner-ops` for many actions.
That existing signal should be preserved and extended rather than discarded.
The generation strategy should be:
1. Start from the original canonical forward ops attached to the local action.
2. Resolve all entity references to stable ids at persistence time.
3. Build inverse canonical ops while the pre-action DB state is still available.
4. Persist both directions on the action row under the same `tx-id`.
Representative inverse mappings:
| Forward op | Inverse op strategy |
| --- | --- |
| `:save-block` | Persist a `:save-block` payload built from pre-action block content and relevant pre-action refs. |
| `:insert-blocks` | Persist `:delete-blocks` for the created roots, or a more specific inverse if a safer canonical form exists. |
| `:move-blocks` | Persist `:move-blocks` back to the pre-action target with stable target id and structural opts. |
| `:delete-blocks` | Persist a hard-delete inverse only if block recreation is represented explicitly. Recycle-based restoration is no longer part of block deletion semantics. |
| `:set-block-property` | Persist `:set-block-property` or `:remove-block-property` depending on whether the property existed before the action. |
| `:batch-set-property` | Persist a batch inverse that restores prior values per block rather than a single blind batch overwrite. |
| `:create-page` | Persist `:delete-page` or a page retract inverse depending on page type. |
| `:delete-page` | Persist `:create-page` plus restoration of prior page content and relationships, or represent page restoration as a dedicated canonical op. |
The plan does not require every mapping to be implemented in one patch.
It does require the migration plan to make unsupported actions explicit so that they cannot silently flow into the new architecture.
## Why `tx-id` Must Be the Main-Thread DB History Reference
The UI stack needs a stable identifier for the DB action because that action may be:
- pending locally and not yet synced
- rebased by the worker
- acknowledged by the server
- rewritten during local compaction
- invalidated because the worker dropped it as unreplayable
If the main thread stores raw tx-data instead of `tx-id`, the UI stack and worker pending history can diverge.
If the main thread stores `tx-id`, the worker can rewrite the row contents while preserving logical action identity.
That makes the worker free to update the concrete replay payload during rebase without breaking the main-thread history pointer.
The worker must therefore treat `tx-id` as logical action identity rather than as a disposable row key.
## Cutover Plan
This change intentionally uses a strict cutover instead of a backward-compatible migration for old pending rows.
Cutover rules:
1. Existing pending rows without semantic persistence fields are cleared or dropped at startup.
2. Existing main-thread stack entries without `tx-id` are invalid after the cutover.
3. Old-format in-memory history is not preserved across the change.
4. New rows must always be written with the new semantic fields once the feature flag is enabled.
## Implementation Plan
### Phase 1. Add action-identity and semantic fields to client-op storage.
- Update `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/client_op.cljs` schema for forward and inverse semantic ops plus version metadata.
- Keep existing fields intact.
- Ensure `tx-id` remains stable when a pending row is rewritten after rebase.
### Phase 2. Make the worker action row the source of truth for DB history.
- Update `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/apply_txs.cljs` so local action persistence writes both directions when available.
- Split the meaning of existing `:db-sync/outliner-ops` from the new explicit forward and inverse fields rather than overloading one field for two roles.
- Preserve current `normalized-tx-data` and `reversed-tx-data` during migration.
### Phase 3. Change main-thread undo stack payloads from embedded tx data to `tx-id` references.
- Update `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/undo_redo.cljs` so stack entries keep UI state and worker action references instead of DB tx payloads.
- Keep cursor and route state on the main thread.
- Remove the assumption that main-thread history owns the DB replay payload.
### Phase 4. Add a worker API for undo and redo execution by `tx-id`.
- Introduce a worker-facing command that resolves the client-op row by `tx-id`.
- Undo execution should replay `inverse-outliner-ops`.
- Redo execution should replay `forward-outliner-ops`.
- The command should return enough result metadata for the main thread to restore cursor and UI state after the worker confirms DB success.
### Phase 5. Replace raw undo/redo sync persistence with semantic persistence.
- Stop classifying new undo/redo executions as canonical raw `:transact` in `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/apply_txs.cljs`.
- Instead, persist their semantic forward and inverse operations under the same logical action identity.
- Remove the old undo/redo raw replay path for pending-row persistence instead of keeping a compatibility branch.
### Phase 6. Extend rebase to use semantic inverse and forward ops for undo/redo rows.
- In `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/apply_txs.cljs`, route undo/redo rows through semantic replay only.
- Preserve one-row-per-user-action atomicity.
- If any op in a row becomes invalid, drop or quarantine the whole row rather than partially replaying it.
### Phase 7. Add explicit invalidation rules for main-thread stack entries.
- If the worker drops or quarantines a referenced action row, the main-thread stack entry that points at that `tx-id` must be invalidated.
- Add a worker-to-main-thread signal so the UI can clear stale history references without guessing.
- Avoid silent dangling `tx-id` references in the UI stack.
### Phase 8. Add compaction and cleanup rules.
- Decide when an acknowledged action row can be compacted while still remaining undoable.
- If compaction rewrites stored payloads, preserve `tx-id`.
- If compaction removes a row, invalidate any stack references to it first.
## Required Code Areas
The following files are expected to change.
| File | Responsibility |
| --- | --- |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/undo_redo.cljs` | Change stack payloads to `tx-id` references and route DB replay to worker by action id. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/handler/history.cljs` | Keep UI restore flow but consume worker-driven undo/redo result metadata. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/client_op.cljs` | Extend client-op schema with semantic forward and inverse fields plus version metadata. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/apply_txs.cljs` | Persist semantic undo/redo ops, keep stable `tx-id`, and use semantic replay in rebase. |
| `/Users/tiensonqin/Codes/projects/logseq/web/deps/outliner/src/logseq/outliner/op.cljs` | Reuse canonical op shapes and extend replay-visible surface if new canonical ops are introduced. |
| `/Users/tiensonqin/Codes/projects/logseq/web/deps/outliner/src/logseq/outliner/recycle.cljs` | Provide a replay-safe restoration entrypoint if `:restore-recycled` becomes canonical. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/test/frontend/undo_redo_test.cljs` | Cover `tx-id`-referenced history behavior and main-thread invalidation. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/test/frontend/worker/db_sync_test.cljs` | Cover semantic persistence and rebase of undo/redo action rows. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/test/frontend/worker/db_sync_sim_test.cljs` | Cover multi-client rebase scenarios where remote txs commute or conflict with undo/redo. |
## Edge Cases That Must Be Designed Explicitly
### Remote non-conflicting updates.
A remote title update on an unrelated block should not invalidate a local undo stack entry.
### Remote updates on the same entity but different attrs.
The design should allow semantic replay when the attrs commute and reject it when they do not.
### Structural conflicts.
Parent, page, order, delete, recycle, and missing-ref conflicts must invalidate the whole action row, not a subset of its ops.
### Undo of a batch action.
One logical batch action should remain one `tx-id` and should not split into multiple undoable rows during persistence or rebase.
### Redo after rebase.
Redo must continue to point at the same action identity even if the worker rewrote the row payloads during remote rebase.
### Session boundary.
Main-thread stacks are in-memory only today.
That remains acceptable, but the new `tx-id` reference model must not assume the stack survives app restart unless a later ADR decides to persist it.
### Unsupported inverse mappings.
An unsupported op must be explicit and traceable.
It must not silently fall back to safe-looking semantic replay if it is actually raw replay.
### Old pending rows.
Existing rows without semantic fields are intentionally not supported after the cutover.
They should be cleared rather than replayed.
## Risks
The largest risk is trying to migrate everything at once.
Undo/redo touches the main thread, the worker, client-op persistence, and rebase logic.
The plan therefore intentionally stages the work so that logical action identity and schema changes land before the main-thread stack rewrite and before the semantic rebase cutover.
Another risk is under-specifying inverse mappings for high-level actions such as delete-page.
Those mappings should be treated as explicit product decisions, not left to raw tx fallback by accident.
Another risk is user-visible loss of old pending rows during the cutover.
That tradeoff is accepted because preserving old raw replay rows would preserve the exact failure mode this ADR is intended to remove.
## Verification Plan
Verification must cover both behavior and migration safety.
Required coverage:
- main-thread undo stack entry stores `tx-id` plus UI state, not raw DB tx payload
- worker persists semantic forward and inverse ops on new local action rows
- undo executes by `tx-id` and replays inverse semantic ops
- redo executes by `tx-id` and replays forward semantic ops
- new undo/redo rows no longer persist as canonical raw `:transact`
- rebase preserves logical `tx-id` while rewriting row payloads
- benign remote updates continue to allow undo/redo replay
- structural conflicts invalidate the whole referenced action row
- dropped or quarantined worker rows invalidate corresponding UI history entries
- old rows without semantic fields are cleared and do not replay
Targeted commands once implementation begins:
- `bb dev:test -v frontend.undo-redo-test`
- `bb dev:test -v frontend.worker.db-sync-test`
- `bb dev:test -v frontend.worker.db-sync-sim-test`
## Consequences
Positive consequences:
- Undo/redo DB history and sync pending history converge on one logical action model.
- Rebase can reason about intent instead of replaying raw datoms for new undo/redo rows.
- The main thread keeps ownership of UI-only state without also becoming the long-term store of DB replay payloads.
- `tx-id` becomes a durable action identity that can survive rebase rewrites.
Negative consequences:
- The worker schema and the main-thread undo model both change at the same time.
- Several inverse-op mappings require explicit product and implementation decisions.
- The cutover is intentionally strict and may discard old pending rows that cannot satisfy the new semantic contract.
## Follow-up Notes
This ADR intentionally does not specify the final retention policy for acknowledged action rows.
It only requires that action-row lifetime be compatible with undo/redo stack references.
If future work decides to persist the UI stack across restarts, that should be handled in a separate ADR after this action-identity model is in place.

View File

@@ -0,0 +1,308 @@
# ADR 0012: Move Undo/Redo Recording and Replay to the DB Worker
Date: 2026-03-21
Status: Proposed
## Context
`frontend.undo-redo` currently runs on the main thread.
That means undo and redo recording depends on main-thread listeners observing DB
tx reports after they have already crossed the worker boundary.
This split has become a recurring source of drift:
- the worker is the source of truth for the browser Datascript DB
- the worker already persists local actions in client-op storage
- the worker already owns rebase and semantic replay
- the main thread still owns undo/redo stack mutation for DB history
That architecture forces the main thread to reconstruct DB history from a
worker-synchronized tx report instead of observing the DB change at the place
where it actually happens.
The result is fragile metadata flow.
We have already seen bugs caused by:
- `:outliner-ops` being stripped or reshaped during worker-to-main-thread sync
- undo/redo-generated tx rows overwriting the original client-op row
- semantic forward and inverse ops diverging between worker persistence and
main-thread undo stack payloads
- special cases such as `:replace-empty-target?`, block concat, and
`:set-block-property` depending on worker-local replay behavior anyway
The one main-thread-only input that `frontend.undo-redo` still needs is
`@state/*editor-info`.
Today that atom is read and reset on the main thread inside
`frontend.undo-redo/gen-undo-ops!`.
If undo/redo recording moves to the worker, the worker can no longer deref the
main-thread atom directly.
## Decision
1. DB undo/redo recording and replay move to the DB worker.
2. The worker becomes the only place that listens to DB tx reports for DB
history generation.
3. The main thread remains responsible for UI-derived history inputs only:
editor cursor/focus metadata and UI-state snapshots.
4. `@state/*editor-info` will not be read from the worker directly.
It will be replaced by an explicit main-thread-to-worker handoff protocol.
5. The worker owns the undo stack and redo stack for DB actions and UI-adjacent
metadata attached to those actions.
6. The main thread will invoke worker APIs for:
- recording pending editor info
- recording UI-only state history entries
- undo
- redo
- clear history
7. The main thread will stop generating DB undo history from `:db/sync-changes`
events.
8. The worker-owned history row should not keep a separate persisted
`:db-sync/outliner-ops` field.
`:db-sync/forward-outliner-ops` is the only canonical persisted forward
semantic field.
## Rationale
The worker is already the place where all browser DB facts become real:
- local outliner ops are applied there
- remote sync txs are applied there
- pending local rows are persisted there
- semantic forward and inverse ops are canonicalized there
- rebase happens there
Undo/redo recording should therefore observe worker DB tx reports directly
instead of reconstructing them after the worker has serialized, sanitized, and
rebroadcast them.
That removes an entire class of metadata transport bugs.
It also matches ADR 0011 more closely: the worker action row is supposed to be
the source of truth for DB history. Recording DB history on the main thread is
in tension with that decision.
## Target Architecture
```text
+------------------------------+ thread-api +---------------------------+
| Main thread | ----------------------------> | DB worker |
| | | |
| editor lifecycle | push pending editor-info | pending editor-info store |
| route/sidebar state | push ui-state entries | undo stack |
| history handler | undo / redo / clear | redo stack |
| restore cursor + route | <---------------------------- | DB replay + result meta |
+------------------------------+ +---------------------------+
```
The worker stack entry becomes the single logical history item for both:
- DB replay metadata
- UI-adjacent metadata needed after replay
Representative worker stack item:
```clojure
{:tx-id #uuid "..."
:kind :db-action ; or :ui-state-only
:editor-info {:block-uuid ...
:container-id ...
:start-pos ...
:end-pos ...}
:ui-state-str "...optional transit..."
:forward-outliner-ops [...]
:inverse-outliner-ops [...]
:outliner-op :save-block}
```
The target row schema is therefore:
- `:db-sync/tx-id`
- `:db-sync/outliner-op`
- `:db-sync/forward-outliner-ops`
- `:db-sync/inverse-outliner-ops`
- worker-owned cursor/UI metadata as needed
It intentionally does not include a separate persisted
`:db-sync/outliner-ops`.
## `*editor-info` Handoff
The worker must not read `@state/*editor-info` directly.
That atom lives on the main thread and represents ephemeral UI state.
Instead, we will replace the implicit shared-state read with an explicit
handoff.
### Rule
The main thread owns editor-info production.
The worker owns editor-info consumption.
### Mechanism
Add a worker-side pending editor-info slot keyed by repo.
Suggested API:
- `:thread-api/undo-redo-set-pending-editor-info`
- args: `repo`, `editor-info-or-nil`
- `:thread-api/undo-redo-record-ui-state`
- args: `repo`, `ui-state-str`
- `:thread-api/undo-redo-undo`
- args: `repo`
- `:thread-api/undo-redo-redo`
- args: `repo`
- `:thread-api/undo-redo-clear-history`
- args: `repo`
### Consumption and reset semantics
When the worker records a new local DB action into undo history:
1. read pending editor-info for the repo
2. attach it to the new stack item if present
3. clear the pending editor-info slot immediately after the stack item is
created
This preserves the current one-shot semantics of `@state/*editor-info` without
requiring the worker to deref or mutate main-thread state directly.
### Main-thread responsibilities
The main thread should:
- capture editor-info at the same points it does today
- send the current snapshot to the worker before the local DB action is
submitted or immediately when editor focus/cursor changes, whichever path is
simpler and consistent
- stop relying on worker `:db/sync-changes` to retroactively capture cursor
state
The main thread may still keep a local `*editor-info` atom for editor UI code,
but it is no longer the undo recorders source of truth.
## UI-State History
UI-state-only undo entries such as route/sidebar snapshots cannot be generated
by the worker from DB tx reports.
Those entries should be pushed explicitly from the main thread into the worker
history stack.
Two entry classes will therefore exist in the worker stack:
1. `:db-action`
2. `:ui-state-only`
Undo/redo execution will return enough metadata for the main thread to restore:
- route
- sidebar state
- editor cursor
The worker should not attempt to perform UI restoration itself.
## Consequences
### Positive
- DB undo/redo history is recorded at the actual DB source of truth.
- No more dependence on `:db/sync-changes` preserving semantic metadata exactly.
- Worker persistence, worker replay, and worker history all use the same action
identity.
- Main-thread history bugs caused by tx-meta sanitization disappear.
- Undo/redo debugging becomes simpler because the worker owns the full DB
history lifecycle.
### Negative
- The worker history stack now stores UI-adjacent metadata that originates on
the main thread.
- New thread APIs are required.
- Main-thread editor lifecycle code must actively synchronize pending
`editor-info`.
- The migration touches both undo/redo and worker message flow at once.
## Implementation Plan
### Phase 1. Introduce worker-owned undo/redo module
- Create a worker namespace, e.g.
`/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/undo_redo.cljs`
- Move stack storage and DB history generation there.
- Register worker DB listener(s) against the worker Datascript conn.
- Remove persisted `:db-sync/outliner-ops` from the target worker history row
shape instead of carrying it forward as a parallel field.
### Phase 2. Replace main-thread DB history generation
- Remove DB-history recording from
`/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/undo_redo.cljs`
- Keep only main-thread coordination helpers if still needed.
- Route history handler calls through worker thread APIs.
### Phase 3. Add pending editor-info handoff
- Add worker API to set pending editor-info.
- Update
`/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/modules/outliner/ui.cljc`
and any direct local transact paths to send editor-info to the worker.
- Consume and clear the pending editor-info slot when a local history item is
recorded.
### Phase 4. Move UI-state history writes to worker
- Replace `record-ui-state!` main-thread stack mutation with worker API calls.
- Keep route/sidebar serialization on the main thread.
### Phase 5. Return worker-owned undo/redo result metadata
- Worker undo/redo APIs should return:
- `:undo?`
- `:editor-info`
- `:ui-state-str`
- optional block content or replay diagnostics
- Main-thread history handler restores cursor and route from that result.
## Files Expected to Change
| File | Change |
| --- | --- |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/undo_redo.cljs` | Remove main-thread DB listener ownership, keep only coordinator logic if still needed. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/handler/history.cljs` | Call worker undo/redo APIs and restore UI from returned metadata. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/modules/outliner/ui.cljc` | Send editor-info snapshots to the worker before local action submission. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/handler/editor/lifecycle.cljs` | Stop recording editor-info directly into main-thread undo stack; feed worker pending editor-info instead. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/db_worker.cljs` | Expose worker thread APIs for pending editor-info, UI-state history, undo, redo, and clear-history. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/db_listener.cljs` | Attach worker undo/redo recording directly to worker DB tx reports. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/client_op.cljs` | Remove `:db-sync/outliner-ops` from the target worker-owned undo/redo row model and use `:db-sync/forward-outliner-ops` instead. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/main/frontend/worker/sync/apply_txs.cljs` | Keep worker replay aligned with worker-owned history rows. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/test/frontend/undo_redo_test.cljs` | Replace main-thread DB-history expectations with coordinator/result expectations. |
| `/Users/tiensonqin/Codes/projects/logseq/web/src/test/frontend/worker/db_sync_test.cljs` | Add worker-owned history recording and replay coverage. |
## Alternatives Considered
### 1. Keep `frontend.undo-redo` on the main thread and preserve more tx-meta
Rejected.
This keeps the wrong ownership boundary.
It reduces one transport bug at a time but does not remove the architectural
duplication between main-thread history and worker DB history.
### 2. Let the worker call back into the main thread to read `@state/*editor-info`
Rejected.
That would create an implicit cross-thread read dependency around ephemeral UI
state.
It is harder to reason about than explicit handoff, and reset semantics become
ambiguous.
### 3. Keep DB history in the worker and UI history in a separate main-thread stack
Possible, but inferior to a single worker-owned stack item keyed by `tx-id`.
It still splits one logical action across two structures and reintroduces
alignment problems.
## Open Questions
1. Should pending editor-info be pushed:
- only at transact boundaries
- or eagerly on every cursor change with last-write-wins semantics?
Recommendation:
push at transact boundaries first.
It matches current one-shot behavior and avoids unnecessary worker chatter.
2. Should `:ui-state-only` entries live in the same stack as DB actions?
Recommendation:
yes.
One logical undo/redo stream is simpler than coordinating two stacks.
3. Do we still need `@state/*editor-info` after the migration?
Recommendation:
keep it as a UI helper until the move is complete, but stop using it as undo
history source of truth.

View File

@@ -0,0 +1,187 @@
# ADR 0013: Worker-Owned Undo/Redo Test Ownership
Date: 2026-03-21
Status: Proposed
## Context
ADR 0012 moves DB undo/redo ownership from the main thread to the db worker.
That architectural move is not complete if the test suite still treats
`src/test/frontend/undo_redo_test.cljs` as the primary place to assert DB
history behavior.
Today the old main-thread file still contains most undo/redo tests, including:
- local tx recording
- semantic forward and inverse metadata
- insert/save/delete replay sequences
- replay conflict and skip behavior
- validation behavior tied to DB replay
Those tests were correct for the old design, but they now encourage the wrong
ownership boundary.
The worker already owns:
- worker datascript DB mutation
- client-op persistence
- semantic replay
- pending action identity
- replay safety decisions
The main thread now mainly owns:
- route/sidebar UI restoration
- cursor restoration from worker result metadata
- thin browser-facing proxy calls
The test suite should reflect that split directly.
## Decision
1. `src/test/frontend/worker/undo_redo_test.cljs` becomes the primary home for
DB-history-focused undo/redo tests.
2. `src/test/frontend/undo_redo_test.cljs` is reduced to main-thread-only
coordination tests.
3. Replay, conflict, and rebase-heavy assertions that are really worker replay
behavior belong in `src/test/frontend/worker/db_sync_test.cljs`, not in
either undo-redo namespace test file.
4. No new DB-history behavior test should be added to
`src/test/frontend/undo_redo_test.cljs`.
## Test Ownership Rules
### Keep in `src/test/frontend/worker/undo_redo_test.cljs`
- local tx recording gates
- semantic metadata persistence
- worker-owned undo stack and redo stack mutation
- canonical action-id and semantic op persistence
- worker-owned DB-history entries that include pending editor-info and UI-state
metadata
### Keep in `src/test/frontend/worker/db_sync_test.cljs`
- `apply-history-action!` replay behavior
- semantic replay correctness
- conflict and skip behavior caused by worker replay safety
- rebase interactions
- client-op row persistence and rewrite behavior
### Keep in `src/test/frontend/undo_redo_test.cljs`
- route/sidebar UI-state restoration on the main thread
- cursor restoration from worker result metadata
- browser-facing proxy semantics in `frontend.undo-redo` and
`frontend.handler.history`
- any pure coordination helper that still lives only on the main thread
## Migration Buckets
### Bucket 1. Recording and metadata tests.
Move these first:
- `undo-records-only-local-txs-test`
- `undo-history-records-semantic-action-metadata-test`
- `undo-history-records-forward-ops-for-editor-save-block-test`
- `undo-history-canonicalizes-insert-block-uuids-test`
These tests map directly to worker history ownership and do not require route or
cursor restoration.
### Bucket 2. Basic local replay tests.
Move next, but only after the worker fixture persists matching client-op rows:
- `undo-works-for-local-graph-test`
- `repeated-save-block-content-undo-redo-test`
- `repeated-editor-save-block-content-undo-redo-test`
- `editor-save-two-blocks-undo-targets-latest-block-test`
- `new-local-save-clears-redo-stack-test`
If a test still depends on browser-only helpers, rewrite it to assert worker DB
behavior directly or leave it temporarily unmoved.
### Bucket 3. Delete/recycle sequence tests.
Reclassify before moving:
- `insert-save-delete-sequence-undo-redo-test`
- `undo-redo-works-for-recycle-delete-test`
If the real behavior under test is semantic replay, keep or move it to
`src/test/frontend/worker/db_sync_test.cljs`.
### Bucket 4. Conflict and skip tests.
Reclassify these into worker replay coverage unless they are truly worker stack
mutation tests:
- `undo-conflict-clears-history-test`
- `undo-works-with-remote-updates-test`
- `undo-skips-when-parent-missing-test`
- `undo-skips-when-block-deleted-remote-test`
- `undo-skips-when-undo-would-create-cycle-test`
- `undo-skips-conflicted-move-and-keeps-earlier-history-test`
- `redo-builds-reversed-tx-when-target-parent-is-recycled-test`
- `undo-skips-move-when-original-parent-is-recycled-test`
### Bucket 5. Main-thread leftovers.
After Buckets 1-4:
- keep only route restoration
- keep only cursor restoration
- keep only proxy/coordination tests
- add a namespace comment in `src/test/frontend/undo_redo_test.cljs` that it is
no longer the DB-history source-of-truth test file
## Implementation Rules
1. Every moved worker test must use a fixture that owns both:
- worker datascript conn
- worker client-ops conn
2. That fixture must route tx reports through both:
- `frontend.worker.sync/enqueue-local-tx!`
- `frontend.worker.undo-redo/gen-undo-ops!`
3. Tests that expect persisted action lookup must attach a stable
`:db-sync/tx-id`.
4. Do not duplicate replay tests between `worker/undo_redo_test.cljs` and
`worker/db_sync_test.cljs`.
5. Delete the original test only after the worker version passes.
## Consequences
### Positive
- The test suite matches the actual runtime ownership boundary.
- Worker replay regressions are caught where they actually happen.
- The old main-thread test file becomes much easier to reason about.
### Negative
- Some existing tests need adaptation rather than straight copy because they
implicitly relied on old main-thread stack storage.
- The migration requires explicit decisions about whether a test is about
worker history ownership or worker replay behavior.
## Verification
Representative focused commands during migration:
```bash
bb dev:test -v frontend.worker.undo-redo-test/undo-records-only-local-txs-test
bb dev:test -v frontend.worker.undo-redo-test/undo-history-records-semantic-action-metadata-test
bb dev:test -v frontend.worker.undo-redo-test/undo-history-canonicalizes-insert-block-uuids-test
bb dev:test -v frontend.worker.db-sync-test/apply-history-action-redo-replays-save-block-test
bb dev:test -v frontend.worker.db-sync-test/apply-history-action-redo-replays-block-concat-test
bb dev:test -v frontend.worker.db-sync-test/apply-history-action-redo-replays-paste-into-empty-target-test
```
## Exit Criteria
- `src/test/frontend/worker/undo_redo_test.cljs` owns the DB-history recording
tests.
- `src/test/frontend/undo_redo_test.cljs` contains only main-thread coordination
tests.
- No worker-owned DB-history scenario is tested only in the main-thread file.

View File

@@ -161,7 +161,7 @@
"photoswipe": "^5.4.4",
"pixi-graph-fork": "0.2.0",
"pixi.js": "6.2.0",
"posthog-js": "1.141.0",
"posthog-js": "1.10.0",
"prop-types": "^15.8.1",
"react": "18.3.1",
"react-dom": "18.3.1",

View File

@@ -26,19 +26,13 @@
file (.getFile file-handle)]
(.text file)))
(comment
(defn <delete-file!
"Delete `filename` from Origin Private File System.
Options:
- :ignore-not-found? (default true) → don't treat missing file as error.
Returns a promise that resolves to nil."
[filename & {:keys [ignore-not-found?]
:or {ignore-not-found? true}}]
(-> (p/let [root (.. js/navigator -storage (getDirectory))]
(.removeEntry root filename))
(p/catch (fn [err]
(if (and ignore-not-found?
(= (.-name err) "NotFoundError"))
nil
(throw err)))))))
(defn <delete-file!
"Delete `filename` from Origin Private File System.
Returns nil when file is missing."
[filename]
(-> (p/let [root (.. js/navigator -storage (getDirectory))]
(.removeEntry root filename))
(p/catch (fn [err]
(if (= (.-name err) "NotFoundError")
nil
(throw err))))))

View File

@@ -98,7 +98,7 @@
#(compare %2 %1)))]
[:div.flex.flex-col.gap-1
[:div.text-sm.text-muted-foreground.mb-4
"Deleted pages and blocks stay here until restored or automatically garbage collected after 60 days."]
"Deleted pages and blocks stay here until restored or automatically garbage collected after 30 days."]
(if (seq groups)
(for [[title roots] groups]
[:section {:key title}

View File

@@ -20,6 +20,7 @@
[frontend.handler.ui :as ui-handler]
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.undo-redo.debug-ui :as undo-redo-debug-ui]
[frontend.util :as util]
[logseq.db :as ldb]
[logseq.shui.hooks :as hooks]
@@ -143,10 +144,15 @@
:shortcut-settings
[[:.flex.items-center (ui/icon "command" {:class "text-md mr-2"}) (t :help/shortcuts)]
(shortcut-settings)]
:rtc
[[:.flex.items-center (ui/icon "cloud" {:class "text-md mr-2"}) "(Dev) RTC"]
(rtc-debug-ui/rtc-debug-ui)]
:undo-redo
[[:.flex.items-center (ui/icon "rotate-clockwise" {:class "text-md mr-2"}) "(Dev) Undo/Redo"]
(undo-redo-debug-ui/undo-redo-debug-ui)]
:profiler
[[:.flex.items-center (ui/icon "cloud" {:class "text-md mr-2"}) "(Dev) Profiler"]
(profiler/profiler)]
@@ -165,6 +171,21 @@
(defonce *drag-from
(atom nil))
(defn dev-sidebar-items
[developer-mode?]
(cond-> []
(and developer-mode? (not config/publishing?))
(conj {:db-id "rtc" :block-type :rtc :label "(Dev) RTC"})
developer-mode?
(conj {:db-id "undo-redo" :block-type :undo-redo :label "(Dev) Undo/Redo"})
developer-mode?
(conj {:db-id "vector-search" :block-type :vector-search :label "(Dev) vector-search"})
developer-mode?
(conj {:db-id "profiler" :block-type :profiler :label "(Dev) Profiler"})))
(rum/defc actions-menu-content
[db-id idx type collapsed? block-count]
(let [multi-items? (> block-count 1)
@@ -417,7 +438,8 @@
state)}
[state repo t blocks]
(let [*anim-finished? (get state ::anim-finished?)
block-count (count blocks)]
block-count (count blocks)
developer-mode? (state/sub [:ui/developer-mode?])]
[:div.cp__right-sidebar-inner.flex.flex-col.h-full#right-sidebar-container
[:div.cp__right-sidebar-scrollable
@@ -443,22 +465,12 @@
(state/sidebar-add-block! repo "help" :help))}
(t :right-side-bar/help)]]
(when (and (state/sub [:ui/developer-mode?]) (not config/publishing?))
[:div.text-sm
[:button.button.cp__right-sidebar-settings-btn {:on-click (fn [_e]
(state/sidebar-add-block! repo "rtc" :rtc))}
"(Dev) RTC"]])
(when (state/sub [:ui/developer-mode?])
[:div.text-sm
(for [{:keys [db-id block-type label]} (dev-sidebar-items developer-mode?)]
[:div.text-sm {:key (str "dev-sidebar-item-" (name block-type))}
[:button.button.cp__right-sidebar-settings-btn
{:on-click (fn [_e]
(state/sidebar-add-block! repo "vector-search" :vector-search))}
"(Dev) vector-search"]])
(when (state/sub [:ui/developer-mode?])
[:div.text-sm
[:button.button.cp__right-sidebar-settings-btn {:on-click (fn [_e]
(state/sidebar-add-block! repo "profiler" :profiler))}
"(Dev) Profiler"]])]]
(state/sidebar-add-block! repo db-id block-type))}
label]])]]
[:.sidebar-item-list.flex-1.scrollbar-spacing.px-2
(if @*anim-finished?

View File

@@ -27,6 +27,8 @@
:graph-uuid nil
:local-tx nil
:remote-tx nil
:local-checksum nil
:remote-checksum nil
:rtc-state :open
:download-logs nil
:upload-logs nil
@@ -61,6 +63,8 @@
:graph-uuid (:graph-uuid state)
:local-tx (:local-tx state)
:remote-tx (:remote-tx state)
:local-checksum (:local-checksum state)
:remote-checksum (:remote-checksum state)
:rtc-state (if (:rtc-lock state) :open :close)))
rtc-flows/rtc-state-flow)))]
(reset! *update-detail-info-canceler canceler))))
@@ -118,7 +122,7 @@
[]
(let [online? (hooks/use-flow-state flows/network-online-event-flow)
[expand-debug? set-expand-debug!] (hooks/use-state false)
{:keys [graph-uuid local-tx remote-tx rtc-state
{:keys [graph-uuid local-tx remote-tx local-checksum remote-checksum rtc-state
download-logs upload-logs misc-logs pending-local-ops pending-server-ops]}
(hooks/use-flow-state (m/watch *detail-info))]
[:div.rtc-info.flex.flex-col.gap-1.p-2.text-gray-11
@@ -144,6 +148,8 @@
graph-uuid (assoc :graph-uuid graph-uuid)
local-tx (assoc :local-tx local-tx)
remote-tx (assoc :remote-tx remote-tx)
local-checksum (assoc :local-checksum local-checksum)
remote-checksum (assoc :remote-checksum remote-checksum)
rtc-state (assoc :rtc-state rtc-state))
pprint/pprint
with-out-str)]])

View File

@@ -53,12 +53,12 @@
(defonce db-sync-ws-url
(if db-sync-local?
"ws://127.0.0.1:8787/sync/%s"
"wss://api.logseq.io/sync/%s"))
"wss://api-staging.logseq.io/sync/%s"))
(defonce db-sync-http-base
(if db-sync-local?
"http://127.0.0.1:8787"
"https://api.logseq.io"))
"https://api-staging.logseq.io"))
;; Feature flags
;; =============

View File

@@ -19,9 +19,6 @@
(def ^:private yyyyMMdd-formatter (tf/formatter "yyyyMMdd"))
(def <q db-async-util/<q)
(def <pull db-async-util/<pull)
(comment
(def <pull-many db-async-util/<pull-many))
(defn <get-files
[graph]

View File

@@ -57,13 +57,3 @@
nil))
(js/console.log "<q skipped tx for inputs:" inputs')))))
result)))))
(defn <pull
([graph id]
(<pull graph '[*] id))
([graph selector id]
(p/let [result' (state/<invoke-db-worker :thread-api/pull graph selector id)]
(when result'
(when-let [conn (db-conn/get-db graph false)]
(d/transact! conn [result']))
result'))))

View File

@@ -5,7 +5,6 @@
[frontend.db.conn :as db-conn]
[frontend.persist-db :as persist-db]
[frontend.state :as state]
[frontend.undo-redo :as undo-redo]
[lambdaisland.glogi :as log]
[logseq.db :as ldb]
[promesa.core :as p]))
@@ -28,7 +27,6 @@
:initial-data initial-data}))
(js/console.error e)
(throw e)))
_ (undo-redo/listen-db-changes! repo conn)
db-name (db-conn/get-repo-path repo)
_ (swap! db-conn/conns assoc db-name conn)
end-time (t/now)]

View File

@@ -21,24 +21,44 @@
(p/resolve! response result))))
response))
(defn- ensure-local-op-tx-id
[tx-meta]
(cond-> (or tx-meta {})
(nil? (:db-sync/tx-id tx-meta))
(assoc :db-sync/tx-id (random-uuid))))
(defn transact [worker-transact repo tx-data tx-meta]
(let [tx-meta' (assoc tx-meta
(let [tx-meta' (-> tx-meta
ensure-local-op-tx-id
(assoc
;; not from remote (rtc)
:local-tx? true)]
:local-tx? true))]
(worker-call (fn async-request []
(worker-transact repo tx-data tx-meta')))))
(p/do!
(state/<invoke-db-worker :thread-api/undo-redo-set-pending-editor-info
repo
(state/get-editor-info))
(worker-transact repo tx-data tx-meta'))))))
(defn apply-outliner-ops
[conn ops opts]
(when (seq ops)
(if util/node-test?
(outliner-op/apply-ops! conn ops opts)
(let [opts' (assoc opts
:client-id (:client-id @state/state)
:local-tx? true)
(let [opts' (-> opts
ensure-local-op-tx-id
(assoc
:client-id (:client-id @state/state)
:local-tx? true))
request #(state/<invoke-db-worker
:thread-api/apply-outliner-ops
(state/get-current-repo)
ops
opts')]
(worker-call request)))))
(frontend.db.transact/worker-call
(fn []
(p/do!
(state/<invoke-db-worker :thread-api/undo-redo-set-pending-editor-info
(state/get-current-repo)
(state/get-editor-info))
(request))))))))

View File

@@ -1,6 +1,8 @@
(ns frontend.handler.common.developer
"Common fns for developer related functionality"
(:require [cljs.pprint :as pprint]
(:require ["/frontend/utils" :as utils]
[cljs.pprint :as pprint]
[clojure.string :as string]
[datascript.impl.entity :as de]
[frontend.db :as db]
[frontend.format.mldoc :as mldoc]
@@ -9,6 +11,7 @@
[frontend.persist-db :as persist-db]
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.util :as util]
[frontend.util.page :as page-util]
[logseq.db.frontend.property :as db-property]
[promesa.core :as p]))
@@ -83,6 +86,46 @@
(defn ^:export validate-db []
(state/<invoke-db-worker :thread-api/validate-db (state/get-current-repo)))
(defn- checksum-export-file-name
[repo]
(-> (or repo "graph")
(string/replace #"^/+" "")
(string/replace #"[\\/]+" "_")
(str "_checksum_" (quot (util/time-ms) 1000))))
(defn ^:export recompute-checksum-diagnostics
[]
(if-let [repo (state/get-current-repo)]
(-> (state/<invoke-db-worker :thread-api/recompute-checksum-diagnostics repo)
(p/then (fn [{:keys [recomputed-checksum local-checksum remote-checksum blocks checksum-attrs e2ee?]
:as result}]
(if (map? result)
(let [export-edn {:repo repo
:generated-at (.toISOString (js/Date.))
:e2ee? e2ee?
:recomputed-checksum recomputed-checksum
:local-checksum local-checksum
:remote-checksum remote-checksum
:checksum-attrs checksum-attrs
:blocks blocks}
content (with-out-str (pprint/pprint export-edn))
blob (js/Blob. #js [content] (clj->js {:type "text/edn;charset=utf-8"}))
filename (checksum-export-file-name repo)]
(utils/saveToFile blob filename "edn")
(notification/show!
(str "Checksum recomputed. Recomputed: " recomputed-checksum
", local: " (or local-checksum "<nil>")
", remote: " (or remote-checksum "<nil>")
". Downloaded " filename ".edn with " (count blocks)
" blocks and checksum attrs " (pr-str checksum-attrs) ".")
:success
false))
(notification/show! "Unable to compute checksum diagnostics for current graph." :warning))))
(p/catch (fn [error]
(js/console.error "recompute-checksum-diagnostics failed:" error)
(notification/show! "Failed to compute graph checksum diagnostics." :error))))
(notification/show! "No graph found" :warning)))
(defn import-chosen-graph
[repo]
(p/let [_ (persist-db/<unsafe-delete repo)]

View File

@@ -2,25 +2,26 @@
"Common fns for file and db based page handlers, including create!, delete!
and favorite fns. This ns should be agnostic of file or db concerns but there
is still some file-specific tech debt to remove from create!"
(:require [clojure.set :as set]
[clojure.string :as string]
[datascript.core :as d]
[dommy.core :as dom]
[frontend.db :as db]
[frontend.db.conn :as conn]
[frontend.handler.config :as config-handler]
[frontend.handler.db-based.editor :as db-editor-handler]
[frontend.handler.notification :as notification]
[frontend.handler.route :as route-handler]
[frontend.handler.ui :as ui-handler]
[frontend.modules.outliner.op :as outliner-op]
[frontend.modules.outliner.ui :as ui-outliner-tx]
[frontend.state :as state]
[logseq.common.config :as common-config]
[logseq.common.util :as common-util]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]
[promesa.core :as p]))
(:require
[clojure.set :as set]
[clojure.string :as string]
[datascript.core :as d]
[dommy.core :as dom]
[frontend.db :as db]
[frontend.db.conn :as conn]
[frontend.handler.config :as config-handler]
[frontend.handler.db-based.editor :as db-editor-handler]
[frontend.handler.notification :as notification]
[frontend.handler.route :as route-handler]
[frontend.handler.ui :as ui-handler]
[frontend.modules.outliner.op :as outliner-op]
[frontend.modules.outliner.ui :as ui-outliner-tx]
[frontend.state :as state]
[logseq.common.config :as common-config]
[logseq.common.util :as common-util]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]
[promesa.core :as p]))
(defn- wrap-tags
"Tags might have multiple words"
@@ -157,18 +158,10 @@
;; =========
(defn after-page-deleted!
[page-name tx-meta]
;; TODO: move favorite && unfavorite to worker too
[page-name]
;; TODO: move favorite && unfavorite to worker too
(when-let [page-block-uuid (:block/uuid (db/get-page page-name))]
(<db-unfavorite-page! page-block-uuid))
(when (and (not= :rename-page (:real-outliner-op tx-meta))
(= (some-> (state/get-current-page) common-util/page-name-sanity-lc)
(common-util/page-name-sanity-lc page-name)))
(route-handler/redirect-to-home!))
;; TODO: why need this?
(ui-handler/re-render-root!))
(<db-unfavorite-page! page-block-uuid)))
(defn after-page-renamed!
[repo {:keys [page-id old-name new-name]}]

View File

@@ -206,6 +206,25 @@
true
(true? graph-e2ee?)))
(defn- <ensure-user-rsa-keys-on-server!
[{:keys [server-rsa-keys-exists?]}]
(if (not= false server-rsa-keys-exists?)
(p/resolved nil)
(if @state/*db-worker
(-> (state/<invoke-db-worker :thread-api/db-sync-ensure-user-rsa-keys
{:ensure-server? true
:server-rsa-keys-exists? false})
(p/catch (fn [error]
(log/error :db-sync/ensure-user-rsa-keys-failed
{:error error
:reason :server-rsa-keys-missing})
nil)))
(do
(log/warn :db-sync/ensure-user-rsa-keys-skipped
{:reason :db-worker-not-ready
:server-rsa-keys-exists? server-rsa-keys-exists?})
(p/resolved nil)))))
(defn- <wait-for-db-worker-ready!
[]
(if @state/*db-worker
@@ -286,6 +305,8 @@
base (http-base)]
(if base
(p/let [_ (js/Promise. user-handler/task--ensure-id&access-token)
_ (state/<invoke-db-worker :thread-api/db-sync-ensure-user-rsa-keys
{:ensure-server? true})
body (coerce-http-request :graphs/create
{:graph-name (string/replace repo config/db-version-prefix "")
:schema-version schema-version
@@ -411,6 +432,8 @@
resp (fetch-json (str base "/graphs")
{:method "GET"}
{:response-schema :graphs/list})
_ (<ensure-user-rsa-keys-on-server! {:server-rsa-keys-exists?
(:user-rsa-keys-exists? resp)})
graphs (:graphs resp)
result (mapv (fn [graph]
(let [graph-e2ee? (if (contains? graph :graph-e2ee?)

View File

@@ -320,6 +320,7 @@
{:outliner-op :insert-blocks}
(save-current-block! {:current-block current-block})
(outliner-op/insert-blocks! [new-block'] current-block {:sibling? sibling?
:right-sibling-id (:db/id (:right-sibling config))
:keep-uuid? keep-uuid?
:ordered-list? ordered-list?
:replace-empty-target? replace-empty-target?
@@ -470,9 +471,9 @@
(defn insert-new-block!
"Won't save previous block content - remember to save!"
([state]
(insert-new-block! state nil))
([_state block-value]
([state right-sibling]
(insert-new-block! state nil right-sibling))
([_state block-value right-sibling]
(->
(when (not config/publishing?)
(when-let [state (get-state)]
@@ -507,7 +508,7 @@
:else
insert-new-block-aux!)
[result-promise sibling? next-block] (insert-fn config block'' value)
[result-promise sibling? next-block] (insert-fn (assoc config :right-sibling right-sibling) block'' value)
edit-block-f (fn []
(let [next-block' (db/entity [:block/uuid (:block/uuid next-block)])
pos 0
@@ -1982,51 +1983,26 @@
(insert-template! element-id db-id {}))
([element-id db-id {:keys [target] :as opts}]
(let [repo (state/get-current-repo)]
(p/let [block (db-async/<pull repo db-id)
block (when (:block/uuid block)
(db-async/<get-block repo (:block/uuid block)
{:children? true}))]
(p/let [block (db-async/<get-block repo db-id {:children? false})]
(when (:db/id block)
(let [journal? (ldb/journal? target)
target (or target (state/get-edit-block))
format (get block :block/format :markdown)
block-uuid (:block/uuid block)
blocks (db/get-block-and-children repo block-uuid {:include-property-block? true})
sorted-blocks (let [blocks' (rest blocks)]
(cons
(-> (first blocks')
(assoc :logseq.property/used-template (:db/id block)))
(rest blocks')))
blocks sorted-blocks]
format (get block :block/format :markdown)]
(when element-id
(insert-command! element-id "" format {:end-pattern commands/command-trigger}))
(let [sibling? (:sibling? opts)
sibling?' (cond
(some? sibling?)
sibling?
(try
(p/let [result (ui-outliner-tx/transact!
{:outliner-op :apply-template
:created-from-journal-template? journal?}
(when-not (string/blank? (state/get-edit-content))
(save-current-block!))
(outliner-op/apply-template! db-id target opts))]
(when result (edit-last-block-after-inserted! result)))
(db/has-children? (:block/uuid target))
false
:else
true)]
(when (seq blocks)
(try
(p/let [result (ui-outliner-tx/transact!
{:outliner-op :insert-blocks
:created-from-journal-template? journal?}
(when-not (string/blank? (state/get-edit-content))
(save-current-block!))
(outliner-op/insert-blocks! blocks target
(assoc opts
:sibling? sibling?'
:insert-template? true)))]
(when result (edit-last-block-after-inserted! result)))
(catch :default ^js/Error e
(notification/show!
(util/format "Template insert error: %s" (.-message e))
:error)))))))))))
(catch :default ^js/Error e
(notification/show!
(util/format "Template insert error: %s" (.-message e))
:error)))))))))
(defn template-on-chosen-handler
[element-id]
@@ -2095,7 +2071,7 @@
input (state/get-input)
config (assoc config :keydown-new-block true)
content (gobj/get input "value")
has-right? (ldb/get-right-sibling block)]
right-sibling (ldb/get-right-sibling block)]
(cond
(and (string/blank? content)
(own-order-number-list? block)
@@ -2105,12 +2081,12 @@
(and
(string/blank? content)
(not has-right?)
(not right-sibling)
(not (last-top-level-child? config block)))
(indent-outdent false)
:else
(insert-new-block! state)))))))
(insert-new-block! state right-sibling)))))))
(defn- inside-of-single-block
"When we are in a single block wrapper, we should always insert a new line instead of new block"
@@ -3111,8 +3087,7 @@
(:block/uuid page-entity)))
repo (state/get-current-repo)
_ (db-async/<get-block repo (or block-id page-id)
{:children? true
:include-collapsed-children? true})
{:include-collapsed-children? true})
entity (db/entity [:block/uuid (or block-id page-id)])
result (or (:block/_page entity)
(rest (db/get-block-and-children repo (:block/uuid entity))))
@@ -3188,8 +3163,7 @@
(defn expand-block! [block-id & {:keys [skip-db-collpsing?]}]
(let [repo (state/get-current-repo)]
(p/do!
(db-async/<get-block repo block-id {:children? true
:include-collapsed-children? true})
(db-async/<get-block repo block-id {:include-collapsed-children? true})
(when-not (or skip-db-collpsing? (skip-collapsing-in-db?))
(set-blocks-collapsed! [block-id] false))
(state/set-collapsed-block! block-id false))))
@@ -3529,9 +3503,9 @@
(ui-outliner-tx/transact!
{:outliner-op :save-block}
(property-handler/set-block-property! (:db/id block) :block/tags :logseq.class/Query)
(save-block-inner! block "" {})
(when query-block
(save-block-inner! query-block current-query {}))))))))
(save-block-inner! query-block current-query {}))
(save-block-inner! block "" {})))))))
(defn quick-add-ensure-new-block-exists!
[]

View File

@@ -3,7 +3,6 @@
[frontend.db :as db]
[frontend.handler.editor :as editor-handler]
[frontend.state :as state]
[frontend.undo-redo :as undo-redo]
[frontend.util :as util]
[goog.dom :as gdom]))
@@ -30,9 +29,12 @@
;; skip recording editor info when undo or redo is still running
(when-not (contains? #{:undo :redo} @(:editor/op @state/state))
(let [page-id (:block/uuid (:block/page (db/entity (:db/id (state/get-edit-block)))))
repo (state/get-current-repo)]
repo (state/get-current-repo)
editor-info (state/get-editor-info)]
(when page-id
(undo-redo/record-editor-info! repo (state/get-editor-info)))))
(state/<invoke-db-worker :thread-api/undo-redo-record-editor-info
repo
editor-info))))
(state/set-state! :editor/op nil))
state)

View File

@@ -56,6 +56,12 @@
(defmulti handle first)
(defonce ^:private *search-index-build-timeout (atom nil))
(def ^:private decrypt-aes-key-failed-notification
"Failed to decrypt this graph.")
(defn- decrypt-aes-key-failed?
[error]
(string/includes? (or (ex-message error) (str error)) "decrypt-aes-key"))
(defn- schedule-search-index-build!
[repo]
@@ -132,9 +138,10 @@
(page-handler/create-today-journal!)
(page-handler/<create! page-name opts)))
(defmethod handle :page/deleted [[_ page-name tx-meta]]
(when-not (util/mobile?)
(page-common-handler/after-page-deleted! page-name tx-meta)))
(defmethod handle :page/deleted [[_ page-name _tx-meta]]
(when page-name
(when-not (util/mobile?)
(page-common-handler/after-page-deleted! page-name))))
(defmethod handle :page/renamed [[_ repo data]]
(when-not (util/mobile?)
@@ -377,8 +384,8 @@
(println "RTC download graph failed, error:")
(log/error :rtc-download-graph-failed e)
(shui/popup-hide! :download-rtc-graph)
;; TODO: notify error
))))
(when (decrypt-aes-key-failed? e)
(notification/show! decrypt-aes-key-failed-notification :error false))))))
;; db-worker -> UI
(defmethod handle :db/sync-changes [[_ data]]

View File

@@ -4,7 +4,6 @@
[frontend.handler.route :as route-handler]
[frontend.persist-db.browser :as db-browser]
[frontend.state :as state]
[frontend.undo-redo :as undo-redo]
[frontend.util :as util]
[goog.functions :refer [debounce]]
[logseq.db :as ldb]
@@ -12,12 +11,21 @@
(defn- restore-cursor!
[{:keys [editor-cursors block-content undo?]}]
(let [{:keys [block-uuid container-id start-pos end-pos]} (if undo? (first editor-cursors) (or (last editor-cursors) (first editor-cursors)))
(let [cursor (if undo?
(first editor-cursors)
(or (last editor-cursors) (first editor-cursors)))
{:keys [selected-block-uuids selection-direction block-uuid container-id start-pos end-pos]} cursor
selected-blocks (when (seq selected-block-uuids)
(->> selected-block-uuids
(mapcat util/get-blocks-by-id)
vec))
pos (if undo? (or start-pos end-pos) (or end-pos start-pos))]
(when-let [block (db/pull [:block/uuid block-uuid])]
(editor/edit-block! block pos
{:container-id container-id
:custom-content block-content}))))
(if (seq selected-blocks)
(state/exit-editing-and-set-selected-blocks! selected-blocks selection-direction)
(when-let [block (db/pull [:block/uuid block-uuid])]
(editor/edit-block! block pos
{:container-id container-id
:custom-content block-content})))))
(defn- restore-app-state!
[state]
@@ -52,7 +60,7 @@
(state/set-state! [:editor/last-replace-ref-content-tx repo] nil)
(editor/save-current-block!)
(state/clear-editor-action!)
(reset! *last-request (undo-redo/undo repo))
(reset! *last-request (state/<invoke-db-worker :thread-api/undo-redo-undo repo))
(p/let [result @*last-request]
(restore-cursor-and-state! result))))))))
(defonce undo! (debounce undo-aux! 20))
@@ -67,7 +75,7 @@
(when-let [repo (state/get-current-repo)]
(util/stop e)
(state/clear-editor-action!)
(reset! *last-request (undo-redo/redo repo))
(reset! *last-request (state/<invoke-db-worker :thread-api/undo-redo-redo repo))
(p/let [result @*last-request]
(restore-cursor-and-state! result)))))))
(defonce redo! (debounce redo-aux! 20))

View File

@@ -16,7 +16,6 @@
[frontend.persist-db :as persist-db]
[frontend.search :as search]
[frontend.state :as state]
[frontend.undo-redo :as undo-redo]
[frontend.util :as util]
[frontend.util.text :as text-util]
[logseq.db.frontend.schema :as db-schema]
@@ -51,10 +50,7 @@
(defn start-repo-db-if-not-exists!
[repo & {:as opts}]
(state/set-current-repo! repo)
(db/start-db-conn! repo (assoc opts
:db-graph? true
:listen-handler (fn [conn]
(undo-redo/listen-db-changes! repo conn)))))
(db/start-db-conn! repo (assoc opts :db-graph? true)))
(defn restore-and-setup-repo!
"Restore the db of a graph from the persisted data, and setup. Create a new

View File

@@ -238,6 +238,14 @@
(auto-fill-refresh-token-from-cognito!)
(state/pub-event! [:user/fetch-info-and-graphs]))
(defn- clear-e2ee-password!
[]
(when @state/*db-worker
(-> (state/<invoke-db-worker :thread-api/clear-e2ee-password)
(p/catch (fn [error]
(js/console.warn :clear-e2ee-password-failed error)
nil)))))
(defn ^:export login-with-username-password-e2e
[username' password client-id client-secret]
(let [text-encoder (new js/TextEncoder)
@@ -265,7 +273,9 @@
{:id-token id-token :access-token access-token :refresh-token refresh-token})))))
(defn logout []
(clear-e2ee-password!)
(clear-tokens)
(.clear js/localStorage)
(state/clear-user-info!)
(state/pub-event! [:user/logout])
(reset! flows/*current-login-user :logout))

View File

@@ -4,7 +4,6 @@
[clojure.string :as string]
[frontend.handler.notification :as notification]
[frontend.state :as state]
[frontend.undo-redo :as undo-redo]
[lambdaisland.glogi :as log]
[logseq.db :as ldb]))
@@ -30,9 +29,6 @@
(defmethod handle :sync-db-changes [_ _worker data]
(state/pub-event! [:db/sync-changes data]))
(defmethod handle :clear-undo-history [_ _worker [repo]]
(undo-redo/clear-history! repo))
(defmethod handle :rtc-log [_ _worker log]
(state/pub-event! [:rtc/log log]))

View File

@@ -42,14 +42,15 @@
(defn- ^js get-hnsw-index
[repo]
(or (@infer-worker.state/*hnsw-index repo)
(let [hnsw-ctor (.-HierarchicalNSW ^js @infer-worker.state/*hnswlib)
hnsw (new hnsw-ctor "cosine" (or (:dims (:hnsw-config (second @infer-worker.state/*model-name+config))) 384) "")
file-exists? (.checkFileExists (.-EmscriptenFileSystemManager ^js @infer-worker.state/*hnswlib) repo)]
(when file-exists?
(.readIndex hnsw repo init-max-elems)
(swap! infer-worker.state/*hnsw-index assoc repo hnsw)
hnsw))))
(when repo
(or (@infer-worker.state/*hnsw-index repo)
(let [hnsw-ctor (.-HierarchicalNSW ^js @infer-worker.state/*hnswlib)
hnsw (new hnsw-ctor "cosine" (or (:dims (:hnsw-config (second @infer-worker.state/*model-name+config))) 384) "")
file-exists? (.checkFileExists (.-EmscriptenFileSystemManager ^js @infer-worker.state/*hnswlib) repo)]
(when file-exists?
(.readIndex hnsw repo init-max-elems)
(swap! infer-worker.state/*hnsw-index assoc repo hnsw)
hnsw)))))
(defn- ^js new-hnsw-index!
[repo]

View File

@@ -35,6 +35,12 @@
(let [id (:db/id target-block)]
[:insert-blocks [blocks id opts]])))
(defn apply-template!
[template-id target-block opts]
(op-transact!
(let [id (:db/id target-block)]
[:apply-template [template-id id opts]])))
(defn delete-blocks!
[blocks opts]
(op-transact!

View File

@@ -23,12 +23,13 @@
(defn invoke-hooks
[{:keys [repo tx-meta tx-data deleted-block-uuids deleted-assets affected-keys blocks]}]
;; (prn :debug
;; :tx-meta tx-meta
;; :tx-data tx-data)
(let [{:keys [initial-pages? end?]} tx-meta
tx-report {:tx-meta tx-meta
:tx-data tx-data}]
:tx-data tx-data}
current-block-id (state/get-current-page)
current-block (when (and current-block-id (util/uuid-string? current-block-id))
(let [id (uuid current-block-id)]
(db/entity [:block/uuid id])))]
(when (= repo (state/get-current-repo))
(when (seq deleted-block-uuids)
(let [ids (map (fn [id] (:db/id (db/entity [:block/uuid id]))) deleted-block-uuids)]
@@ -68,7 +69,11 @@
tx-data))]
(d/transact! conn tx-data' tx-meta))
(when-not (= (:client-id tx-meta) (:client-id @state/state))
(when (and current-block (ldb/recycled? (db/entity [:block/uuid (:block/uuid current-block)])))
(route-handler/redirect! {:to :home :push false}))
(when (or (not= (:client-id tx-meta) (:client-id @state/state))
(= :apply-template (:outliner-op tx-meta)))
(update-editing-block-title-if-changed! tx-data))
;; (when (seq deleted-assets)

View File

@@ -508,6 +508,9 @@
:dev/validate-db {:binding []
:inactive (not (state/developer-mode?))
:fn :frontend.handler.common.developer/validate-db}
:dev/recompute-checksum {:binding []
:inactive (not (state/developer-mode?))
:fn :frontend.handler.common.developer/recompute-checksum-diagnostics}
:dev/rtc-stop {:binding []
:inactive (not (state/developer-mode?))
:fn :frontend.handler.common.developer/rtc-stop}
@@ -710,6 +713,7 @@
:dev/show-page-data
:dev/replace-graph-with-db-file
:dev/validate-db
:dev/recompute-checksum
:dev/gc-graph
:dev/rtc-stop
:dev/rtc-start
@@ -873,6 +877,7 @@
:dev/show-page-data
:dev/replace-graph-with-db-file
:dev/validate-db
:dev/recompute-checksum
:dev/gc-graph
:dev/rtc-stop
:dev/rtc-start

View File

@@ -48,7 +48,9 @@
old-state (f prev)
new-state (f current)]
(when (not= new-state old-state)
(undo-redo/record-ui-state! (state/get-current-repo) (ldb/write-transit-str {:old-state old-state :new-state new-state}))))))))
(let [repo (state/get-current-repo)
ui-state-str (ldb/write-transit-str {:old-state old-state :new-state new-state})]
(undo-redo/record-ui-state! repo ui-state-str))))))))
(defn transact!
[repo tx-data tx-meta]

View File

@@ -2044,11 +2044,18 @@ Similar to re-frame subscriptions"
(defn get-editor-info
[]
(when-let [edit-block (get-edit-block)]
{:block-uuid (:block/uuid edit-block)
:container-id (or @(:editor/container-id @state) :unknown-container)
:start-pos @(:editor/start-pos @state)
:end-pos (get-edit-pos)}))
(let [selected-block-uuids (some-> (get-selection-block-ids) seq vec)
selection-info (when selected-block-uuids
{:selected-block-uuids selected-block-uuids
:selection-direction (get-selection-direction)})]
(if-let [edit-block (get-edit-block)]
(cond-> {:block-uuid (:block/uuid edit-block)
:container-id (or @(:editor/container-id @state) :unknown-container)
:start-pos @(:editor/start-pos @state)
:end-pos (get-edit-pos)}
selection-info
(merge selection-info))
selection-info)))
(defn conj-block-ref!
[ref-entity]

View File

@@ -1,378 +1,69 @@
(ns frontend.undo-redo
"Undo redo new implementation"
(:require [datascript.core :as d]
[frontend.db :as db]
[frontend.state :as state]
[frontend.util :as util]
[lambdaisland.glogi :as log]
[logseq.common.defkeywords :refer [defkeywords]]
[logseq.db :as ldb]
[logseq.outliner.recycle :as outliner-recycle]
[logseq.undo-redo-validate :as undo-validate]
[malli.core :as m]
[malli.util :as mu]
[promesa.core :as p]))
"Main-thread proxy for worker-owned undo/redo."
(:require [frontend.state :as state]
[frontend.util :as util]))
(defkeywords
::record-editor-info {:doc "record current editor and cursor"}
::db-transact {:doc "db tx"}
::ui-state {:doc "ui state such as route && sidebar blocks"})
(defn- worker-not-initialized?
[e]
(= "db-worker has not been initialized" (ex-message e)))
;; TODO: add other UI states such as `::ui-updates`.
(comment
;; TODO: convert it to a qualified-keyword
(sr/defkeyword :gen-undo-ops?
"tx-meta option, generate undo ops from tx-data when true (default true)"))
(defn- normalize-empty-result
[result]
(case result
:frontend.worker.undo-redo/empty-undo-stack
:frontend.undo-redo/empty-undo-stack
(def ^:private undo-op-item-schema
(mu/closed-schema
[:multi {:dispatch first}
[::db-transact
[:cat :keyword
[:map
[:tx-data [:sequential [:fn
{:error/message "should be a Datom"}
d/datom?]]]
[:tx-meta [:map {:closed false}
[:outliner-op :keyword]]]
[:added-ids [:set :int]]
[:retracted-ids [:set :int]]]]]
:frontend.worker.undo-redo/empty-redo-stack
:frontend.undo-redo/empty-redo-stack
[::record-editor-info
[:cat :keyword
[:map
[:block-uuid :uuid]
[:container-id [:or :int [:enum :unknown-container]]]
[:start-pos [:maybe :int]]
[:end-pos [:maybe :int]]]]]
result))
[::ui-state
[:cat :keyword :string]]]))
(def ^:private undo-op-validator (m/validator [:sequential undo-op-item-schema]))
(defonce max-stack-length 100)
(defonce *undo-ops (atom {}))
(defonce *redo-ops (atom {}))
(defn- invoke-db-worker
[thread-api & args]
(try
(apply state/<invoke-db-worker thread-api args)
(catch :default e
(if (worker-not-initialized? e)
nil
(throw e)))))
(defn clear-history!
[repo]
(swap! *undo-ops assoc repo [])
(swap! *redo-ops assoc repo []))
(defn- conj-op
[col op]
(let [result (conj (if (empty? col) [] col) op)]
(if (>= (count result) max-stack-length)
(subvec result 0 (/ max-stack-length 2))
result)))
(defn- pop-stack
[stack]
(when (seq stack)
[(last stack) (pop stack)]))
(defn- push-undo-op
[repo op]
(assert (undo-op-validator op) {:op op})
(swap! *undo-ops update repo conj-op op))
(defn- push-redo-op
[repo op]
(assert (undo-op-validator op) {:op op})
(swap! *redo-ops update repo conj-op op))
(comment
;; This version checks updated datoms by other clients, allows undo and redo back
;; to the current state.
;; The downside is that it'll undo the changes made by others.
(defn- pop-undo-op
[repo conn]
(let [undo-stack (get @*undo-ops repo)
[op undo-stack*] (pop-stack undo-stack)]
(swap! *undo-ops assoc repo undo-stack*)
(mapv (fn [item]
(if (= (first item) ::db-transact)
(let [m (second item)
tx-data' (mapv
(fn [{:keys [e a v tx add] :as datom}]
(let [one-value? (= :db.cardinality/one (:db/cardinality (d/entity @conn a)))
new-value (when (and one-value? add) (get (d/entity @conn e) a))
value-not-matched? (and (some? new-value) (not= v new-value))]
(if value-not-matched?
;; another client might updated `new-value`, the datom below will be used
;; to restore the the current state when redo this undo.
(d/datom e a new-value tx add)
datom)))
(:tx-data m))]
[::db-transact (assoc m :tx-data tx-data')])
item))
op))))
(defn- pop-undo-op
[repo]
(let [undo-stack (get @*undo-ops repo)
[op undo-stack*] (pop-stack undo-stack)]
(swap! *undo-ops assoc repo undo-stack*)
(let [op' (mapv (fn [item]
(if (= (first item) ::db-transact)
(let [m (second item)
tx-data' (vec (:tx-data m))]
(if (seq tx-data')
[::db-transact (assoc m :tx-data tx-data')]
::db-transact-no-tx-data))
item))
op)]
(when-not (some #{::db-transact-no-tx-data} op')
op'))))
(defn- pop-redo-op
[repo]
(let [redo-stack (get @*redo-ops repo)
[op redo-stack*] (pop-stack redo-stack)]
(swap! *redo-ops assoc repo redo-stack*)
(let [op' (mapv (fn [item]
(if (= (first item) ::db-transact)
(let [m (second item)
tx-data' (vec (:tx-data m))]
(if (seq tx-data')
[::db-transact (assoc m :tx-data tx-data')]
::db-transact-no-tx-data))
item))
op)]
(when-not (some #{::db-transact-no-tx-data} op')
op'))))
(defn- empty-undo-stack?
[repo]
(empty? (get @*undo-ops repo)))
(defn- empty-redo-stack?
[repo]
(empty? (get @*redo-ops repo)))
(defn- reverse-datoms
[conn datoms schema added-ids retracted-ids undo? redo?]
(keep
(fn [[e a v _tx add?]]
(let [ref? (= :db.type/ref (get-in schema [a :db/valueType]))
op (if (or (and redo? add?) (and undo? (not add?)))
:db/add
:db/retract)]
(when (or (not ref?)
(d/entity @conn v)
(and (retracted-ids v) undo?)
(and (added-ids v) redo?)) ; entity exists
[op e a v])))
datoms))
(defn- datom-attr
[datom]
(or (nth datom 1 nil)
(:a datom)))
(defn- datom-value
[datom]
(or (nth datom 2 nil)
(:v datom)))
(defn- datom-added?
[datom]
(let [value (nth datom 4 nil)]
(if (some? value)
value
(:added datom))))
(defn- reversed-move-target-ref
[datoms attr undo?]
(some (fn [datom]
(let [a (datom-attr datom)
v (datom-value datom)
added (datom-added? datom)]
(when (and (= a attr)
(if undo? (not added) added))
v)))
datoms))
(defn- reversed-structural-target-conflicted?
[conn e->datoms undo?]
(some (fn [[_e datoms]]
(let [target-parent (reversed-move-target-ref datoms :block/parent undo?)
target-page (reversed-move-target-ref datoms :block/page undo?)
parent-ent (when (int? target-parent) (d/entity @conn target-parent))
page-ent (when (int? target-page) (d/entity @conn target-page))]
(or (and target-parent
(or (nil? parent-ent)
(ldb/recycled? parent-ent)))
(and target-page
(or (nil? page-ent)
(ldb/recycled? page-ent))))))
e->datoms))
(defn get-reversed-datoms
[conn undo? {:keys [tx-data added-ids retracted-ids]} tx-meta]
(let [recycle-restore-tx (when (and undo?
(= :delete-blocks (:outliner-op tx-meta)))
(->> tx-data
(keep (fn [datom]
(let [e (or (nth datom 0 nil)
(:e datom))
a (datom-attr datom)
added (datom-added? datom)]
(when (and added
(= :logseq.property/deleted-at a))
(d/entity @conn e)))))
(mapcat #(outliner-recycle/restore-tx-data @conn %))
seq))
redo? (not undo?)
e->datoms (->> (if redo? tx-data (reverse tx-data))
(group-by :e))
schema (:schema @conn)
structural-target-conflicted? (and undo?
(reversed-structural-target-conflicted? conn e->datoms undo?))
reversed-tx-data (if structural-target-conflicted?
nil
(or (some-> recycle-restore-tx reverse seq)
(->> (mapcat
(fn [[e datoms]]
(cond
(and undo? (contains? added-ids e))
[[:db/retractEntity e]]
(and redo? (contains? retracted-ids e))
[[:db/retractEntity e]]
:else
(reverse-datoms conn datoms schema added-ids retracted-ids undo? redo?)))
e->datoms)
(remove nil?))))]
reversed-tx-data))
(defn- undo-redo-aux
[repo undo?]
(if-let [op (not-empty ((if undo? pop-undo-op pop-redo-op) repo))]
(let [conn (db/get-db repo false)]
(cond
(= ::ui-state (ffirst op))
(do
((if undo? push-redo-op push-undo-op) repo op)
(let [ui-state-str (second (first op))]
{:undo? undo?
:ui-state-str ui-state-str}))
:else
(let [{:keys [tx-data tx-meta] :as data} (some #(when (= ::db-transact (first %))
(second %)) op)]
(when (seq tx-data)
(let [reversed-tx-data (cond-> (get-reversed-datoms conn undo? data tx-meta)
undo?
reverse)
tx-meta' (-> tx-meta
(assoc
:gen-undo-ops? false
:undo? undo?
:redo? (not undo?)))
handler (fn handler []
((if undo? push-redo-op push-undo-op) repo op)
(let [editor-cursors (->> (filter #(= ::record-editor-info (first %)) op)
(map second))
block-content (:block/title (d/entity @conn [:block/uuid (:block-uuid
(if undo?
(first editor-cursors)
(last editor-cursors)))]))]
{:undo? undo?
:editor-cursors editor-cursors
:block-content block-content}))]
(if (seq reversed-tx-data)
(if (undo-validate/valid-undo-redo-tx? conn reversed-tx-data)
(if util/node-test?
(try
(ldb/transact! conn reversed-tx-data tx-meta')
(handler)
(catch :default e
(log/error ::undo-redo-failed e)
(clear-history! repo)
(if undo? ::empty-undo-stack ::empty-redo-stack)))
(->
(p/do!
;; async write to the master worker
(ldb/transact! repo reversed-tx-data tx-meta')
(handler))
(p/catch (fn [e]
(log/error ::undo-redo-failed e)
(clear-history! repo)))))
(do
(log/warn ::undo-redo-skip-invalid-op
{:undo? undo?
:outliner-op (:outliner-op tx-meta)})
(undo-redo-aux repo undo?)))
(do
(log/warn ::undo-redo-skip-conflicted-op
{:undo? undo?
:outliner-op (:outliner-op tx-meta)})
(undo-redo-aux repo undo?))))))))
(when ((if undo? empty-undo-stack? empty-redo-stack?) repo)
(if undo? ::empty-undo-stack ::empty-redo-stack))))
(if util/node-test?
nil
(invoke-db-worker :thread-api/undo-redo-clear-history repo)))
(defn undo
[repo]
(undo-redo-aux repo true))
(if util/node-test?
:frontend.undo-redo/empty-undo-stack
(or (some-> (invoke-db-worker :thread-api/undo-redo-undo repo)
normalize-empty-result)
:frontend.undo-redo/empty-undo-stack)))
(defn redo
[repo]
(undo-redo-aux repo false))
(if util/node-test?
:frontend.undo-redo/empty-redo-stack
(or (some-> (invoke-db-worker :thread-api/undo-redo-redo repo)
normalize-empty-result)
:frontend.undo-redo/empty-redo-stack)))
(defn record-editor-info!
[repo editor-info]
(swap! *undo-ops
update repo
(fn [stack]
(if (seq stack)
(update stack (dec (count stack))
(fn [op]
(conj (vec op) [::record-editor-info editor-info])))
stack))))
(when editor-info
(if util/node-test?
nil
(invoke-db-worker :thread-api/undo-redo-record-editor-info repo editor-info))))
(defn record-ui-state!
[repo ui-state-str]
(when ui-state-str
(push-undo-op repo [[::ui-state ui-state-str]])))
(if util/node-test?
nil
(invoke-db-worker :thread-api/undo-redo-record-ui-state repo ui-state-str))))
(defn gen-undo-ops!
[repo {:keys [tx-data tx-meta db-after db-before]}]
(let [{:keys [outliner-op local-tx?]} tx-meta]
(when (and
(= (:client-id tx-meta) (:client-id @state/state))
(true? local-tx?)
outliner-op
(not (false? (:gen-undo-ops? tx-meta)))
(not (:create-today-journal? tx-meta)))
(let [all-ids (distinct (map :e tx-data))
retracted-ids (set
(filter
(fn [id] (and (nil? (d/entity db-after id)) (d/entity db-before id)))
all-ids))
added-ids (set
(filter
(fn [id] (and (nil? (d/entity db-before id)) (d/entity db-after id)))
all-ids))
tx-data' (vec tx-data)
editor-info @state/*editor-info
_ (reset! state/*editor-info nil)
op (->> [(when editor-info [::record-editor-info editor-info])
[::db-transact
{:tx-data tx-data'
:tx-meta tx-meta
:added-ids added-ids
:retracted-ids retracted-ids}]]
(remove nil?)
vec)]
;; A new local edit invalidates any redo history.
(swap! *redo-ops assoc repo [])
(push-undo-op repo op)))))
(defn listen-db-changes!
[repo conn]
(d/listen! conn ::gen-undo-ops
(fn [tx-report] (gen-undo-ops! repo tx-report))))
(defn <get-debug-state
[repo]
(when-not util/node-test?
(invoke-db-worker :thread-api/undo-redo-get-debug-state repo)))

View File

@@ -0,0 +1,173 @@
(ns frontend.undo-redo.debug-ui
"Debug UI for undo/redo history"
(:require [fipp.edn :as fipp]
[frontend.handler.history :as history-handler]
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.undo-redo :as undo-redo]
[logseq.shui.ui :as shui]
[rum.core :as rum]))
(defn- strip-tx-data
[x]
(cond
(map? x)
(reduce-kv (fn [m k v]
(if (= :tx-data k)
m
(assoc m k (strip-tx-data v))))
{}
x)
(vector? x)
(mapv strip-tx-data x)
(seq? x)
(map strip-tx-data x)
(set? x)
(set (map strip-tx-data x))
:else
x))
(defn- entry-title
[entry]
(let [entry' (if (vector? entry) entry (vec entry))]
(->> entry'
(keep (fn [item]
(cond
(keyword? item) (name item)
(and (vector? item) (keyword? (first item))) (name (first item))
:else nil)))
first
(or "entry"))))
(def ^:private ui-entry-tags
#{::undo-redo/ui-state
::undo-redo/record-editor-info
:frontend.worker.undo-redo/ui-state
:frontend.worker.undo-redo/record-editor-info})
(defn- ui-entry-item?
[item]
(and (vector? item)
(contains? ui-entry-tags (first item))))
(defn- filter-ui-items
[entry]
(if (vector? entry)
(->> entry
(remove ui-entry-item?)
vec)
entry))
(defn- empty-filtered-entry?
[entry]
(and (vector? entry)
(empty? entry)))
(rum/defc payload-entry
[expanded?* id entry]
(let [expanded? (contains? @expanded?* id)]
[:div.rounded-md.border.p-2
[:button.flex.w-full.items-center.justify-between.text-left
{:aria-expanded expanded?
:on-click (fn [_]
(swap! expanded?*
(fn [expanded]
(if (contains? expanded id)
(disj expanded id)
(conj expanded id)))))}
[:span.text-sm.font-medium (entry-title entry)]
[:span.opacity-60 (ui/rotating-arrow (not expanded?))]]
(when expanded?
[:pre.select-text.mt-2.text-xs.overflow-auto
(-> (strip-tx-data entry)
(fipp/pprint {:width 60})
with-out-str)])]))
(rum/defc payload-stack
[expanded?* label entries]
[:div.flex.flex-col.gap-2
[:div.text-sm.font-medium label]
(if (seq entries)
(for [[idx entry] (map-indexed vector (reverse entries))]
(rum/with-key
(payload-entry expanded?* (str label "-" idx) entry)
(str label "-" idx)))
[:div.text-sm.opacity-50 "Empty"])])
(rum/defcs undo-redo-debug-ui < rum/reactive
(rum/local #{} ::expanded)
(rum/local false ::filter-ui-state?)
(rum/local nil ::history)
[state]
(let [repo (state/sub :git/current-repo)
history* (::history state)
_ (rum/react history*)
refresh! (fn []
(when repo
(-> (undo-redo/<get-debug-state repo)
(.then #(reset! history* %))))
nil)
undo-stack (or (:undo-ops @history*) [])
redo-stack (or (:redo-ops @history*) [])
expanded?* (::expanded state)
filter-ui-state?* (::filter-ui-state? state)
filter-ui-state? @filter-ui-state?*
filter-stack (fn [stack]
(if filter-ui-state?
(->> stack
(map filter-ui-items)
(remove empty-filtered-entry?))
stack))
undo-stack' (filter-stack undo-stack)
redo-stack' (filter-stack redo-stack)]
[:div.flex.flex-col.gap-3
[:div.flex.gap-2.flex-wrap.items-center
(shui/button
{:size :sm
:disabled (or (nil? repo) (empty? undo-stack))
:on-click (fn [e]
(history-handler/undo! e)
(js/setTimeout refresh! 0))}
(shui/tabler-icon "arrow-back-up") "undo")
(shui/button
{:size :sm
:disabled (or (nil? repo) (empty? redo-stack))
:on-click (fn [e]
(history-handler/redo! e)
(js/setTimeout refresh! 0))}
(shui/tabler-icon "arrow-forward-up") "redo")
(shui/button
{:size :sm
:variant :outline
:disabled (nil? repo)
:on-click (fn [_]
(undo-redo/clear-history! repo)
(js/setTimeout refresh! 0))}
(shui/tabler-icon "trash") "clear-history")
(shui/button
{:size :sm
:variant :outline
:disabled (nil? repo)
:on-click (fn [_] (refresh!))}
(shui/tabler-icon "refresh") "refresh")
(shui/button
{:size :sm
:variant (if filter-ui-state? :default :outline)
:on-click (fn [_]
(swap! filter-ui-state?* not))}
(shui/tabler-icon "filter") "filter-ui-entry-global")]
[:div.text-sm.opacity-70
(str "undo=" (count undo-stack')
(when filter-ui-state?
(str "/" (count undo-stack)))
" redo=" (count redo-stack')
(when filter-ui-state?
(str "/" (count redo-stack))))]
(payload-stack expanded?* "Undo" undo-stack')
(payload-stack expanded?* "Redo" redo-stack')]))

View File

@@ -6,6 +6,7 @@
[frontend.worker.db.migrate :as db-migrate]
[frontend.worker.shared-service :as shared-service]
[logseq.db :as ldb]
[logseq.db-sync.checksum :as sync-checksum]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.validate :as db-validate]))
@@ -220,35 +221,47 @@
{:fix-db? true})))
(defn validate-db
[conn]
(fix-extends-cardinality! conn)
(fix-icon-wrong-type! conn)
(db-migrate/ensure-built-in-data-exists! conn)
(fix-non-closed-values! conn)
(fix-num-prefix-db-idents! conn)
([conn]
(validate-db nil conn nil))
([_repo conn _options]
(fix-extends-cardinality! conn)
(fix-icon-wrong-type! conn)
(db-migrate/ensure-built-in-data-exists! conn)
(fix-non-closed-values! conn)
(fix-num-prefix-db-idents! conn)
(let [db @conn
{:keys [errors datom-count entities]} (db-validate/validate-db! db)
invalid-entity-ids (distinct (map (fn [e] (:db/id (:entity e))) errors))]
(let [db @conn
{:keys [errors datom-count entities]} (db-validate/validate-db! db)
invalid-entity-ids (distinct (map (fn [e] (:db/id (:entity e))) errors))]
(doseq [error errors]
(prn :debug
:entity (:entity error)
:error (dissoc error :entity)))
(doseq [error errors]
(prn :debug
:entity (:entity error)
:error (dissoc error :entity)))
(if errors
(do
(fix-invalid-blocks! conn errors)
(shared-service/broadcast-to-clients! :log [:db-invalid :error
{:msg "Validation errors"
:errors errors}])
(shared-service/broadcast-to-clients! :notification
[(str "Validation detected " (count errors) " invalid block(s). These blocks may be buggy. Attempting to fix invalid blocks. Run validation again to see if they were fixed.")
:warning false]))
(if errors
(do
(fix-invalid-blocks! conn errors)
(shared-service/broadcast-to-clients! :log [:db-invalid :error
{:msg "Validation errors"
:errors errors}])
(shared-service/broadcast-to-clients! :notification
[(str "Validation detected " (count errors) " invalid block(s). These blocks may be buggy. Attempting to fix invalid blocks. Run validation again to see if they were fixed.")
:warning false]))
(shared-service/broadcast-to-clients! :notification
[(str "Your graph is valid! " (assoc (db-validate/graph-counts db entities) :datoms datom-count))
:success false]))
{:errors errors
:datom-count datom-count
:invalid-entity-ids invalid-entity-ids}))
(shared-service/broadcast-to-clients! :notification
[(str "Your graph is valid! " (assoc (db-validate/graph-counts db entities) :datoms datom-count))
:success false]))
{:errors errors
:datom-count datom-count
:invalid-entity-ids invalid-entity-ids})))
(defn recompute-checksum-diagnostics
[_repo conn {:keys [local-checksum remote-checksum] :as _sync-diagnostics}]
(let [{:keys [checksum attrs blocks e2ee?]} (sync-checksum/recompute-checksum-diagnostics @conn)]
{:recomputed-checksum checksum
:local-checksum local-checksum
:remote-checksum remote-checksum
:e2ee? e2ee?
:checksum-attrs attrs
:blocks blocks}))

View File

@@ -14,6 +14,15 @@
(defmulti listen-db-changes
(fn [listen-key & _] listen-key))
(defn- transit-safe-tx-meta
[tx-meta]
(when (map? tx-meta)
(->> tx-meta
(remove (fn [[k v]]
(or (= :error-handler k)
(fn? v))))
(into {}))))
(defn- sync-db-to-main-thread
"Return tx-report"
[repo conn {:keys [tx-meta] :as tx-report}]
@@ -28,7 +37,7 @@
{:repo repo
:request-id (:request-id tx-meta)
:tx-data (:tx-data tx-report')
:tx-meta tx-meta}
:tx-meta (transit-safe-tx-meta tx-meta)}
(dissoc result :tx-report))]
(shared-service/broadcast-to-clients! :sync-db-changes data))
@@ -84,12 +93,13 @@
(d/listen! conn ::listen-db-changes!
(fn listen-db-changes!-inner
[{:keys [tx-data tx-meta] :as tx-report}]
(remove-old-embeddings-and-reset-new-updates! conn tx-data tx-meta)
(when (and (seq tx-data) (not (:mark-embedding? tx-meta)))
(let [tx-report' (if sync-db-to-main-thread?
(sync-db-to-main-thread repo conn tx-report)
tx-report)
opt {:repo repo}]
(db-sync/update-local-sync-checksum! repo tx-report')
(doseq [[k handler-fn] handlers]
(handler-fn k opt tx-report'))))))))
(when-not (:batch-tx? @conn)
(remove-old-embeddings-and-reset-new-updates! conn tx-data tx-meta)
(when (and (seq tx-data) (not (:mark-embedding? tx-meta)))
(let [tx-report' (if sync-db-to-main-thread?
(sync-db-to-main-thread repo conn tx-report)
tx-report)
opt {:repo repo}]
(db-sync/update-local-sync-checksum! repo tx-report')
(doseq [[k handler-fn] handlers]
(handler-fn k opt tx-report')))))))))

View File

@@ -19,7 +19,6 @@
[frontend.worker.db.validate :as worker-db-validate]
[frontend.worker.embedding :as embedding]
[frontend.worker.export :as worker-export]
[frontend.worker.handler.page :as worker-page]
[frontend.worker.pipeline :as worker-pipeline]
[frontend.worker.publish]
[frontend.worker.search :as search]
@@ -31,6 +30,7 @@
[frontend.worker.sync.crypt :as sync-crypt]
[frontend.worker.sync.log-and-state :as rtc-log-and-state]
[frontend.worker.thread-atom]
[frontend.worker.undo-redo :as worker-undo-redo]
[goog.object :as gobj]
[lambdaisland.glogi :as log]
[lambdaisland.glogi.console :as glogi-console]
@@ -50,12 +50,12 @@
[logseq.db.sqlite.export :as sqlite-export]
[logseq.db.sqlite.gc :as sqlite-gc]
[logseq.db.sqlite.util :as sqlite-util]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.op :as outliner-op]
[logseq.outliner.recycle :as outliner-recycle]
[me.tonsky.persistent-sorted-set :as set :refer [BTSet]]
[missionary.core :as m]
[promesa.core :as p]))
[promesa.core :as p]
[goog.functions :as gfun]))
(def ^:private worker-bootstrap-loaded-key "__logseq_db_worker_bootstrap_loaded__")
@@ -85,6 +85,8 @@
(defonce *opfs-pools worker-state/*opfs-pools)
(defonce *publishing? (atom false))
(defonce ^:private *search-index-build-ids (atom {}))
(defonce ^:private *client-ops-cleanup-timers (atom {}))
(def ^:private client-ops-cleanup-interval-ms (* 3 60 60 1000))
(defn- check-worker-scope!
[]
@@ -180,9 +182,13 @@
(defn- close-db-aux!
[repo ^Object db ^Object search ^Object client-ops]
(when-let [timer (get @*client-ops-cleanup-timers repo)]
(js/clearInterval timer))
(swap! *client-ops-cleanup-timers dissoc repo)
(swap! *sqlite-conns dissoc repo)
(swap! *datascript-conns dissoc repo)
(swap! *client-ops-conns dissoc repo)
(swap! client-op/*repo->pending-local-tx-count dissoc repo)
(swap! *search-index-build-ids dissoc repo)
(search/clear-fuzzy-search-indice! repo)
(when db (.close db))
@@ -259,6 +265,23 @@
:kv/value (common-util/time-ms)}]
{:skip-validate-db? true}))))
(defn- run-client-ops-cleanup!
[repo]
(let [protected-tx-ids (worker-undo-redo/referenced-history-tx-ids repo)]
(client-op/cleanup-finished-history-ops! repo protected-tx-ids)
nil))
(defn- ensure-client-ops-cleanup-timer!
[repo]
(when (and (not @*publishing?)
repo
(nil? (get @*client-ops-cleanup-timers repo)))
(let [timer (js/setInterval (fn []
(run-client-ops-cleanup! repo))
client-ops-cleanup-interval-ms)]
(swap! *client-ops-cleanup-timers assoc repo timer))
nil))
(def ^:private recycle-gc-kv :logseq.kv/recycle-last-gc-at)
(defn- maybe-run-recycle-gc!
@@ -289,6 +312,7 @@
(when-not @*publishing? (common-sqlite/create-kvs-table! client-ops-db))
(search/create-tables-and-triggers! search-db)
(ldb/register-transact-pipeline-fn! worker-pipeline/transact-pipeline)
(ldb/register-debounce-fn! (gfun/debounce d/store 1000))
(let [conn (common-sqlite/get-storage-conn storage db-schema/schema)
_ (db-fix/check-and-fix-schema! conn)
_ (when datoms
@@ -321,6 +345,7 @@
(swap! *client-ops-conns assoc repo client-ops-conn)
(when (and (not @*publishing?) (not= client-op/schema-in-db (d/schema @client-ops-conn)))
(d/reset-schema! client-ops-conn client-op/schema-in-db))
(ensure-client-ops-cleanup-timer! repo)
(let [initial-tx-report (when-not (or initial-data-exists?
(seq datoms)
sync-download-graph?)
@@ -473,8 +498,8 @@
(sync-crypt/<grant-graph-access! repo graph-id target-email))
(def-thread-api :thread-api/db-sync-ensure-user-rsa-keys
[]
(sync-crypt/ensure-user-rsa-keys!))
[& [opts]]
(sync-crypt/ensure-user-rsa-keys! opts))
(def-thread-api :thread-api/db-sync-upload-graph
[repo]
@@ -636,6 +661,38 @@
(log/error ::worker-transact-failed e)
(throw e)))))
(def-thread-api :thread-api/undo-redo-set-pending-editor-info
[repo editor-info]
(worker-undo-redo/set-pending-editor-info! repo editor-info)
nil)
(def-thread-api :thread-api/undo-redo-record-editor-info
[repo editor-info]
(worker-undo-redo/record-editor-info! repo editor-info)
nil)
(def-thread-api :thread-api/undo-redo-record-ui-state
[repo ui-state-str]
(worker-undo-redo/record-ui-state! repo ui-state-str)
nil)
(def-thread-api :thread-api/undo-redo-undo
[repo]
(worker-undo-redo/undo repo))
(def-thread-api :thread-api/undo-redo-redo
[repo]
(worker-undo-redo/redo repo))
(def-thread-api :thread-api/undo-redo-clear-history
[repo]
(worker-undo-redo/clear-history! repo)
nil)
(def-thread-api :thread-api/undo-redo-get-debug-state
[repo]
(worker-undo-redo/get-debug-state repo))
(def-thread-api :thread-api/get-initial-data
[repo opts]
(when-let [conn (worker-state/get-datascript-conn repo)]
@@ -966,10 +1023,22 @@
(when-let [conn (worker-state/get-datascript-conn repo)]
(worker-export/get-all-page->content @conn options)))
(defn- sync-diagnostics-for-validation
[repo]
{:local-tx (client-op/get-local-tx repo)
:remote-tx (get @db-sync/*repo->latest-remote-tx repo)
:local-checksum (client-op/get-local-checksum repo)
:remote-checksum (get @db-sync/*repo->latest-remote-checksum repo)})
(def-thread-api :thread-api/validate-db
[repo]
(when-let [conn (worker-state/get-datascript-conn repo)]
(worker-db-validate/validate-db conn)))
(worker-db-validate/validate-db repo conn (sync-diagnostics-for-validation repo))))
(def-thread-api :thread-api/recompute-checksum-diagnostics
[repo]
(when-let [conn (worker-state/get-datascript-conn repo)]
(worker-db-validate/recompute-checksum-diagnostics repo conn (sync-diagnostics-for-validation repo))))
;; Returns an export-edn map for given repo. When there's an unexpected error, a map
;; with key :export-edn-error is returned
@@ -1110,36 +1179,6 @@
dbs (ldb/read-transit-str r)]
(p/all (map #(.unsafeUnlinkDB this (:name %)) dbs)))))
(defn- delete-page!
[conn page-uuid opts]
(let [error-handler (fn [{:keys [msg]}]
(worker-util/post-message :notification
[[:div [:p msg]] :error]))]
(worker-page/delete! conn page-uuid (merge opts {:error-handler error-handler}))))
(defn- create-page!
[conn title options]
(try
(worker-page/create! conn title options)
(catch :default e
(js/console.error e)
(throw e))))
(defn- outliner-register-op-handlers!
[]
(outliner-op/register-op-handlers!
{:create-page (fn [conn [title options]]
(create-page! conn title options))
:rename-page (fn [conn [page-uuid new-title]]
(if (string/blank? new-title)
(throw (ex-info "Page name shouldn't be blank" {:block/uuid page-uuid
:block/title new-title}))
(outliner-core/save-block! conn
{:block/uuid page-uuid
:block/title new-title})))
:delete-page (fn [conn [page-uuid opts]]
(delete-page! conn page-uuid opts))}))
(defn- on-become-master
[repo start-opts]
(js/Promise.
@@ -1232,7 +1271,6 @@
(log/set-levels {:glogi/root :info})
(log/add-handler worker-state/log-append!)
(check-worker-scope!)
(outliner-register-op-handlers!)
(js/setInterval #(.postMessage js/self "keepAliveResponse") (* 1000 25))
(Comlink/expose proxy-object)
(let [^js wrapped-main-thread* (Comlink/wrap js/self)

View File

@@ -1,21 +1,21 @@
(ns frontend.worker.sync
"Sync client"
(:require [frontend.worker.shared-service :as shared-service]
[frontend.worker.state :as worker-state]
[frontend.worker.sync.apply-txs :as sync-apply]
[frontend.worker.sync.assets :as sync-assets]
[frontend.worker.sync.auth :as sync-auth]
[frontend.worker.sync.client-op :as client-op]
[frontend.worker.sync.crypt :as sync-crypt]
[frontend.worker.sync.handle-message :as sync-handle-message]
[frontend.worker.sync.large-title :as sync-large-title]
[frontend.worker.sync.presence :as sync-presence]
[frontend.worker.sync.transport :as sync-transport]
[frontend.worker.sync.upload :as sync-upload]
[lambdaisland.glogi :as log]
[logseq.common.util :as common-util]
[logseq.db-sync.checksum :as sync-checksum]
[promesa.core :as p]))
(:require
[frontend.worker.shared-service :as shared-service]
[frontend.worker.state :as worker-state]
[frontend.worker.sync.apply-txs :as sync-apply]
[frontend.worker.sync.assets :as sync-assets]
[frontend.worker.sync.auth :as sync-auth]
[frontend.worker.sync.client-op :as client-op]
[frontend.worker.sync.handle-message :as sync-handle-message]
[frontend.worker.sync.large-title :as sync-large-title]
[frontend.worker.sync.presence :as sync-presence]
[frontend.worker.sync.transport :as sync-transport]
[frontend.worker.sync.upload :as sync-upload]
[lambdaisland.glogi :as log]
[logseq.common.util :as common-util]
[logseq.db-sync.checksum :as sync-checksum]
[promesa.core :as p]))
(def ^:private reconnect-base-delay-ms 1000)
(def ^:private reconnect-max-delay-ms 30000)
@@ -24,6 +24,7 @@
(def ^:private ws-stale-timeout-ms 600000)
(defonce *repo->latest-remote-tx sync-apply/*repo->latest-remote-tx)
(defonce *repo->latest-remote-checksum sync-apply/*repo->latest-remote-checksum)
(defonce *start-inflight-target (atom nil))
(defn fail-fast
@@ -40,16 +41,18 @@
(sync-presence/sync-counts
{:get-datascript-conn worker-state/get-datascript-conn
:get-client-ops-conn worker-state/get-client-ops-conn
:get-pending-local-tx-count client-op/get-pending-local-tx-count
:get-unpushed-asset-ops-count client-op/get-unpushed-asset-ops-count
:get-local-tx client-op/get-local-tx
:get-local-checksum client-op/get-local-checksum
:get-graph-uuid client-op/get-graph-uuid
:latest-remote-tx @*repo->latest-remote-tx}
:latest-remote-tx @*repo->latest-remote-tx
:latest-remote-checksum @*repo->latest-remote-checksum}
repo))
(defn update-local-sync-checksum!
[repo tx-report]
(when (and (worker-state/get-client-ops-conn repo)
(not (sync-crypt/graph-e2ee? repo)))
(when (worker-state/get-client-ops-conn repo)
(client-op/update-local-checksum
repo
(sync-checksum/update-checksum (client-op/get-local-checksum repo) tx-report))))

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@
#(do (log/error ::bad-ops (:value %))
(ma/-fail! ::ops-schema (select-keys % [:value])))))
(def ^:private asset-op-types #{:update-asset :remove-asset})
(defonce *repo->pending-local-tx-count (atom {}))
(def schema-in-db
"TODO: rename this db-name from client-op to client-metadata+op.
@@ -41,10 +41,14 @@
:db-sync/checksum {:db/index true}
:db-sync/tx-id {:db/unique :db.unique/identity}
:db-sync/created-at {:db/index true}
:db-sync/pending? {:db/index true}
:db-sync/outliner-op {}
:db-sync/tx-data {}
:db-sync/forward-outliner-ops {}
:db-sync/inverse-outliner-ops {}
:db-sync/inferred-outliner-ops? {}
:db-sync/normalized-tx-data {}
:db-sync/reversed-tx-data {}})
:db-sync/reversed-tx-data {}
:db-sync/asset-op? {:db/index true}})
(defn update-graph-uuid
[repo graph-uuid]
@@ -101,6 +105,24 @@
;; (assert (some? r))
r)))
(defn get-pending-local-tx-count
[repo]
(if-let [cached (get @*repo->pending-local-tx-count repo)]
cached
(let [count' (if-let [conn (worker-state/get-client-ops-conn repo)]
(count (d/datoms @conn :avet :db-sync/pending? true))
0)]
(swap! *repo->pending-local-tx-count assoc repo count')
count')))
(defn adjust-pending-local-tx-count!
[repo delta]
(swap! *repo->pending-local-tx-count
(fn [m]
(let [base (or (get m repo) 0)
next (max 0 (+ base delta))]
(assoc m repo next)))))
(defn get-local-checksum
[repo]
(let [conn (worker-state/get-client-ops-conn repo)]
@@ -135,12 +157,14 @@
(let [remove-asset-op (get exist-block-ops-entity :remove-asset)]
(when-not (already-removed? remove-asset-op t)
(cond-> [{:block/uuid block-uuid
:db-sync/asset-op? true
:update-asset op}]
remove-asset-op (conj [:db.fn/retractAttribute e :remove-asset]))))
:remove-asset
(let [update-asset-op (get exist-block-ops-entity :update-asset)]
(when-not (update-after-remove? update-asset-op t)
(cond-> [{:block/uuid block-uuid
:db-sync/asset-op? true
:remove-asset op}]
update-asset-op (conj [:db.fn/retractAttribute e :update-asset]))))))]
(ldb/transact! conn tx-data)))))))
@@ -149,11 +173,9 @@
[repo]
(let [conn (worker-state/get-datascript-conn repo)
_ (assert (some? conn))
asset-block-uuids (d/q '[:find [?block-uuid ...]
:where
[?b :block/uuid ?block-uuid]
[?b :logseq.property.asset/type]]
@conn)
asset-block-uuids (->> (d/datoms @conn :avet :logseq.property.asset/type)
(keep (fn [d]
(:block/uuid (d/entity @conn (:e d))))))
ops (map
(fn [block-uuid] [:update-asset 1 {:block-uuid block-uuid}])
asset-block-uuids)]
@@ -161,19 +183,10 @@
(defn- get-all-asset-ops*
[db]
(->> (d/datoms db :eavt)
(group-by :e)
(keep (fn [[e datoms]]
(let [op-map (into {}
(keep (fn [datom]
(let [a (:a datom)]
(when (or (keyword-identical? :block/uuid a) (contains? asset-op-types a))
[a (:v datom)]))))
datoms)]
(when (and (:block/uuid op-map)
;; count>1 = contains some `asset-op-types`
(> (count op-map) 1))
[e op-map]))))
(->> (d/datoms db :avet :db-sync/asset-op?)
(map (fn [d]
(let [op (d/entity db (:e d))]
[(:e d) (into {} op)])))
(into {})))
(defn get-unpushed-asset-ops-count
@@ -191,4 +204,25 @@
(when-let [conn (worker-state/get-client-ops-conn repo)]
(let [ent (d/entity @conn [:block/uuid asset-uuid])]
(when-let [e (:db/id ent)]
(ldb/transact! conn (map (fn [a] [:db.fn/retractAttribute e a]) asset-op-types))))))
(ldb/transact! conn [[:db/retractEntity e]])))))
(defn cleanup-finished-history-ops!
[repo protected-tx-ids]
(if-let [conn (worker-state/get-client-ops-conn repo)]
(let [protected-tx-ids (set protected-tx-ids)
tx-ent-ids (->> (d/datoms @conn :avet :db-sync/tx-id)
(keep (fn [datom]
(let [tx-id (:v datom)
ent (d/entity @conn (:e datom))]
(when (and (uuid? tx-id)
(false? (:db-sync/pending? ent))
(not (contains? protected-tx-ids tx-id)))
(:db/id ent)))))
vec)]
(when (seq tx-ent-ids)
(ldb/transact! conn
(mapv (fn [ent-id]
[:db/retractEntity ent-id])
tx-ent-ids)))
(count tx-ent-ids))
0))

View File

@@ -39,6 +39,10 @@
[]
(worker-state/<invoke-main-thread :thread-api/native-get-e2ee-password))
(defn- <native-delete-password-text!
[]
(worker-state/<invoke-main-thread :thread-api/native-delete-e2ee-password))
(defn- <save-e2ee-password
[refresh-token password]
(p/let [result (crypt/<encrypt-text-by-text-password refresh-token password)
@@ -60,6 +64,19 @@
password (crypt/<decrypt-text-by-text-password refresh-token data)]
password))
(defn- <clear-e2ee-password!
[]
(p/let [_ (when (native-worker?)
(-> (<native-delete-password-text!)
(p/catch (fn [e]
(log/error :native-delete-e2ee-password {:error e})
nil))))
_ (-> (opfs/<delete-file! e2ee-password-file)
(p/catch (fn [e]
(log/error :opfs-delete-e2ee-password {:error e})
nil)))]
nil))
(defn- auth-token []
(worker-state/get-id-token))
@@ -243,31 +260,51 @@
_ (<set-user-rsa-key-pair-to-idb! base user-id pair)]
pair)))
(defn- <ensure-user-rsa-key-pair-raw
(defn- <generate-and-upload-user-rsa-key-pair!
[base]
(p/let [existing (<get-user-rsa-key-pair-raw base)]
(if (and (string? (:public-key existing))
(string? (:encrypted-private-key existing)))
(p/let [{:keys [publicKey privateKey]} (crypt/<generate-rsa-key-pair)
{:keys [password]} (worker-state/<invoke-main-thread :thread-api/request-e2ee-password)
encrypted-private-key (crypt/<encrypt-private-key password privateKey)
exported-public-key (crypt/<export-public-key publicKey)
public-key-str (ldb/write-transit-str exported-public-key)
encrypted-private-key-str (ldb/write-transit-str encrypted-private-key)]
(p/let [_ (<upload-user-rsa-key-pair! base public-key-str encrypted-private-key-str)]
{:public-key public-key-str
:encrypted-private-key encrypted-private-key-str
:password password})))
(defn- <ensure-user-rsa-key-pair-raw
[base {:keys [ensure-server? server-rsa-keys-exists?]}]
(p/let [existing (<get-user-rsa-key-pair-raw base)
existing-valid? (user-rsa-key-pair-valid? existing)
server-rsa-keys-exists?
(cond
(boolean? server-rsa-keys-exists?) server-rsa-keys-exists?
(and ensure-server? existing-valid?)
(p/let [server-pair (<fetch-user-rsa-key-pair-raw base)]
(user-rsa-key-pair-valid? server-pair))
:else nil)]
(cond
(and existing-valid? (not= false server-rsa-keys-exists?))
existing
(p/let [{:keys [publicKey privateKey]} (crypt/<generate-rsa-key-pair)
{:keys [password]} (worker-state/<invoke-main-thread :thread-api/request-e2ee-password)
encrypted-private-key (crypt/<encrypt-private-key password privateKey)
exported-public-key (crypt/<export-public-key publicKey)
public-key-str (ldb/write-transit-str exported-public-key)
encrypted-private-key-str (ldb/write-transit-str encrypted-private-key)]
(p/let [_ (<upload-user-rsa-key-pair! base public-key-str encrypted-private-key-str)]
{:public-key public-key-str
:encrypted-private-key encrypted-private-key-str
:password password})))))
existing-valid?
(p/let [_ (<upload-user-rsa-key-pair! base (:public-key existing) (:encrypted-private-key existing))]
existing)
:else
(<generate-and-upload-user-rsa-key-pair! base))))
(defn ensure-user-rsa-keys!
[]
(let [base (e2ee-base)]
(if (string? base)
(<ensure-user-rsa-key-pair-raw base)
(do
(log/info :db-sync/skip-ensure-user-rsa-keys {:reason :missing-e2ee-base})
(p/resolved nil)))))
([]
(ensure-user-rsa-keys! nil))
([opts]
(let [base (e2ee-base)]
(if (string? base)
(<ensure-user-rsa-key-pair-raw base opts)
(do
(log/info :db-sync/skip-ensure-user-rsa-keys {:reason :missing-e2ee-base})
(p/resolved nil))))))
(defn- <decrypt-private-key
[encrypted-private-key-str]
@@ -294,6 +331,13 @@
{:method "GET"}
{:response-schema :e2ee/graph-aes-key}))
(defn- <fetch-graph-encrypted-aes-key
[base graph-id]
(when graph-id
(p/let [resp (<fetch-graph-encrypted-aes-key-raw base graph-id)]
(when-let [encrypted-aes-key (:encrypted-aes-key resp)]
(ldb/read-transit-str encrypted-aes-key)))))
(defn- <upsert-graph-encrypted-aes-key!
[base graph-id encrypted-aes-key]
(let [body (coerce-http-request :e2ee/graph-aes-key
@@ -309,7 +353,7 @@
(defn- <load-user-rsa-key-material
[base user-id graph-id]
(letfn [(<load-once []
(p/let [{:keys [public-key encrypted-private-key]} (<ensure-user-rsa-key-pair-raw base)
(p/let [{:keys [public-key encrypted-private-key]} (<ensure-user-rsa-key-pair-raw base nil)
_ (when-not (and (string? public-key) (string? encrypted-private-key))
(fail-fast :db-sync/missing-field
{:base base
@@ -347,12 +391,29 @@
local-encrypted (when graph-id
(<get-item (graph-encrypted-aes-key-idb-key graph-id)))
remote-encrypted (when (and (nil? local-encrypted) graph-id)
(p/let [resp (<fetch-graph-encrypted-aes-key-raw base graph-id)]
(when-let [encrypted-aes-key (:encrypted-aes-key resp)]
(ldb/read-transit-str encrypted-aes-key))))
(<fetch-graph-encrypted-aes-key base graph-id))
encrypted-aes-key (or local-encrypted remote-encrypted)
aes-key (if encrypted-aes-key
(crypt/<decrypt-aes-key private-key encrypted-aes-key)
(-> (crypt/<decrypt-aes-key private-key encrypted-aes-key)
(p/catch (fn [error]
(if-not (and graph-id local-encrypted)
(throw error)
(let [aes-key-k (graph-encrypted-aes-key-idb-key graph-id)]
(-> (p/let [_ (<clear-item! aes-key-k)
refetched-encrypted (<fetch-graph-encrypted-aes-key base graph-id)]
(if-not refetched-encrypted
(throw error)
(p/let [aes-key (crypt/<decrypt-aes-key private-key refetched-encrypted)
_ (<set-item! aes-key-k refetched-encrypted)]
aes-key)))
(p/catch (fn [retry-error]
(log/warn :db-sync/graph-aes-key-cache-invalid
{:base base
:user-id user-id
:graph-id graph-id
:first-error error
:retry-error retry-error})
(throw retry-error)))))))))
(p/let [aes-key (crypt/<generate-aes-key)
encrypted (crypt/<encrypt-aes-key public-key aes-key)
encrypted-str (ldb/write-transit-str encrypted)
@@ -370,20 +431,39 @@
aes-key-k (graph-encrypted-aes-key-idb-key graph-id)]
(when-not (and (string? base) (string? graph-id))
(fail-fast :db-sync/missing-field {:base base :graph-id graph-id}))
(p/let [{:keys [public-key encrypted-private-key]} (<get-user-rsa-key-pair-raw base)]
(<clear-item! aes-key-k)
(when-not (and (string? public-key) (string? encrypted-private-key))
(fail-fast :db-sync/missing-field {:graph-id graph-id :field :user-rsa-key-pair}))
(p/let [private-key (<decrypt-private-key encrypted-private-key)
encrypted-aes-key (p/let [resp (<fetch-graph-encrypted-aes-key-raw base graph-id)]
(when-let [encrypted-aes-key (:encrypted-aes-key resp)]
(ldb/read-transit-str encrypted-aes-key)))]
(if-not encrypted-aes-key
(fail-fast :db-sync/missing-field {:graph-id graph-id :field :encrypted-aes-key})
(<set-item! aes-key-k encrypted-aes-key))
(p/let [aes-key (crypt/<decrypt-aes-key private-key encrypted-aes-key)]
(swap! *graph->aes-key assoc graph-id aes-key)
aes-key)))))
(letfn [(<fetch-once [{:keys [public-key encrypted-private-key]}]
(p/let [_ (<clear-item! aes-key-k)]
(when-not (and (string? public-key) (string? encrypted-private-key))
(fail-fast :db-sync/missing-field {:graph-id graph-id :field :user-rsa-key-pair}))
(p/let [private-key (<decrypt-private-key encrypted-private-key)
encrypted-aes-key (p/let [resp (<fetch-graph-encrypted-aes-key-raw base graph-id)]
(when-let [encrypted-aes-key (:encrypted-aes-key resp)]
(ldb/read-transit-str encrypted-aes-key)))]
(if-not encrypted-aes-key
(fail-fast :db-sync/missing-field {:graph-id graph-id :field :encrypted-aes-key})
(<set-item! aes-key-k encrypted-aes-key))
(p/let [aes-key (crypt/<decrypt-aes-key private-key encrypted-aes-key)]
(swap! *graph->aes-key assoc graph-id aes-key)
aes-key))))]
(p/let [pair (<get-user-rsa-key-pair-raw base)]
(-> (<fetch-once pair)
(p/catch
(fn [error]
(let [user-id (get-user-uuid)]
(if (and (= "decrypt-aes-key" (ex-message error))
(string? user-id))
(-> (p/let [_ (<clear-user-rsa-key-pair-cache! base user-id)
refreshed-pair (<get-user-rsa-key-pair-raw base)]
(<fetch-once refreshed-pair))
(p/catch (fn [retry-error]
(log/warn :db-sync/user-rsa-key-cache-invalid-on-download
{:base base
:user-id user-id
:graph-id graph-id
:first-error error
:retry-error retry-error})
(throw retry-error))))
(throw error))))))))))
(defn <grant-graph-access!
[repo graph-id target-email]
@@ -614,3 +694,7 @@
(def-thread-api :thread-api/save-e2ee-password
[refresh-token password]
(<save-e2ee-password refresh-token password))
(def-thread-api :thread-api/clear-e2ee-password
[]
(<clear-e2ee-password!))

View File

@@ -28,10 +28,13 @@
(sync-presence/sync-counts
{:get-datascript-conn worker-state/get-datascript-conn
:get-client-ops-conn worker-state/get-client-ops-conn
:get-pending-local-tx-count client-op/get-pending-local-tx-count
:get-unpushed-asset-ops-count client-op/get-unpushed-asset-ops-count
:get-local-tx client-op/get-local-tx
:get-local-checksum client-op/get-local-checksum
:get-graph-uuid client-op/get-graph-uuid
:latest-remote-tx @sync-apply/*repo->latest-remote-tx}
:latest-remote-tx @sync-apply/*repo->latest-remote-tx
:latest-remote-checksum @sync-apply/*repo->latest-remote-checksum}
repo))
(defn- broadcast-rtc-state!
@@ -95,7 +98,11 @@
(defn- pending-local-tx?
[repo]
(when-let [conn (client-ops-conn repo)]
(boolean (first (d/datoms @conn :avet :db-sync/created-at)))))
(boolean
(some (fn [datom]
(let [ent (d/entity @conn (:e datom))]
(not= false (:db-sync/pending? ent))))
(d/datoms @conn :avet :db-sync/created-at)))))
(defn- checksum-compare-ready?
[repo client local-t remote-t]
@@ -115,8 +122,7 @@
(defn- verify-sync-checksum!
[repo client local-tx remote-tx remote-checksum context]
(when (and (not (sync-crypt/graph-e2ee? repo))
(string? remote-checksum)
(when (and (string? remote-checksum)
(checksum-compare-ready? repo client local-tx remote-tx))
(let [local-checksum (local-sync-checksum repo)]
(when-not (= local-checksum remote-checksum)
@@ -249,6 +255,8 @@
remote-checksum (:checksum message)]
(when remote-tx
(swap! sync-apply/*repo->latest-remote-tx assoc repo remote-tx))
(when (contains? message :checksum)
(swap! sync-apply/*repo->latest-remote-checksum assoc repo remote-checksum))
(case (:type message)
"hello" (handle-hello! repo client local-tx remote-tx remote-checksum)
"online-users" (handle-online-users! repo client message)

View File

@@ -16,17 +16,25 @@
(defn sync-counts
[{:keys [get-datascript-conn
get-client-ops-conn
get-pending-local-tx-count
get-unpushed-asset-ops-count
get-local-tx
get-local-checksum
get-graph-uuid
latest-remote-tx]}
latest-remote-tx
latest-remote-checksum]}
repo]
(when (get-datascript-conn repo)
(let [pending-local (when-let [conn (client-ops-conn get-client-ops-conn repo)]
(count (d/datoms @conn :avet :db-sync/created-at)))
(let [pending-local (if get-pending-local-tx-count
(get-pending-local-tx-count repo)
(when-let [conn (client-ops-conn get-client-ops-conn repo)]
(count (d/datoms @conn :avet :db-sync/pending? true))))
pending-asset (get-unpushed-asset-ops-count repo)
local-tx (get-local-tx repo)
remote-tx (get latest-remote-tx repo)
local-checksum (when get-local-checksum
(get-local-checksum repo))
remote-checksum (get latest-remote-checksum repo)
pending-server (when (and (number? local-tx) (number? remote-tx))
(max 0 (- remote-tx local-tx)))
graph-uuid (get-graph-uuid repo)]
@@ -35,6 +43,8 @@
:pending-server pending-server
:local-tx local-tx
:remote-tx remote-tx
:local-checksum local-checksum
:remote-checksum remote-checksum
:graph-uuid graph-uuid})))
(defn normalize-online-users
@@ -54,7 +64,8 @@
(let [repo (:repo client)
ws-state @(:ws-state client)
online-users @(:online-users client)
{:keys [pending-local pending-asset pending-server local-tx remote-tx graph-uuid]}
{:keys [pending-local pending-asset pending-server
local-tx remote-tx local-checksum remote-checksum graph-uuid]}
(sync-counts-f repo)]
{:rtc-state {:ws-state ws-state}
:rtc-lock (= :open ws-state)
@@ -64,6 +75,8 @@
:pending-server-ops-count (or pending-server 0)
:local-tx local-tx
:remote-tx remote-tx
:local-checksum local-checksum
:remote-checksum remote-checksum
:graph-uuid graph-uuid}))
(defn set-ws-state!

View File

@@ -0,0 +1,367 @@
(ns frontend.worker.undo-redo
"Undo redo new implementation"
(:require [datascript.core :as d]
[frontend.worker.state :as worker-state]
[lambdaisland.glogi :as log]
[logseq.common.defkeywords :refer [defkeywords]]
[malli.core :as m]
[malli.util :as mu]))
(defkeywords
::record-editor-info {:doc "record current editor and cursor"}
::db-transact {:doc "db tx"}
::ui-state {:doc "ui state such as route && sidebar blocks"})
(defonce *apply-history-action! (atom nil))
;; TODO: add other UI states such as `::ui-updates`.
(comment
;; TODO: convert it to a qualified-keyword
(sr/defkeyword :gen-undo-ops?
"tx-meta option, generate undo ops from tx-data when true (default true)"))
(def ^:private selection-editor-info-schema
[:map
[:selected-block-uuids [:sequential :uuid]]
[:selection-direction {:optional true} [:maybe [:enum :up :down]]]])
(def ^:private editor-cursor-info-schema
[:map
[:block-uuid :uuid]
[:container-id [:or :int [:enum :unknown-container]]]
[:start-pos [:maybe :int]]
[:end-pos [:maybe :int]]
[:selected-block-uuids {:optional true} [:sequential :uuid]]
[:selection-direction {:optional true} [:maybe [:enum :up :down]]]])
(def ^:private undo-op-item-schema
(mu/closed-schema
[:multi {:dispatch first}
[::db-transact
[:cat :keyword
[:map
[:tx-meta [:map {:closed false}
[:outliner-op :keyword]]]
[:added-ids [:set :int]]
[:retracted-ids [:set :int]]
[:db-sync/tx-id {:optional true} :uuid]
[:db-sync/forward-outliner-ops {:optional true} [:sequential :any]]
[:db-sync/inverse-outliner-ops {:optional true} [:sequential :any]]]]]
[::record-editor-info
[:cat :keyword
[:or
editor-cursor-info-schema
selection-editor-info-schema]]]
[::ui-state
[:cat :keyword :string]]]))
(def ^:private undo-op-validator (m/validator [:sequential undo-op-item-schema]))
(defonce max-stack-length 250)
(defonce *undo-ops (atom {}))
(defonce *redo-ops (atom {}))
(defonce *pending-editor-info (atom {}))
(defn clear-history!
[repo]
(swap! *undo-ops assoc repo [])
(swap! *redo-ops assoc repo [])
(swap! *pending-editor-info dissoc repo))
(defn set-pending-editor-info!
[repo editor-info]
(if editor-info
(swap! *pending-editor-info assoc repo editor-info)
(swap! *pending-editor-info dissoc repo)))
(defn- take-pending-editor-info!
[repo]
(let [editor-info (get @*pending-editor-info repo)]
(swap! *pending-editor-info dissoc repo)
editor-info))
(defn- conj-op
[col op]
(let [result (conj (if (empty? col) [] col) op)]
(if (>= (count result) max-stack-length)
(subvec result 0 (/ max-stack-length 2))
result)))
(defn- pop-stack
[stack]
(when (seq stack)
[(last stack) (pop stack)]))
(defn- push-undo-op
[repo op]
(assert (undo-op-validator op) {:op op})
(swap! *undo-ops update repo conj-op op))
(defn- push-redo-op
[repo op]
(assert (undo-op-validator op) {:op op})
(swap! *redo-ops update repo conj-op op))
(defn- pop-undo-op
[repo]
(let [undo-stack (get @*undo-ops repo)
[op undo-stack*] (pop-stack undo-stack)]
(swap! *undo-ops assoc repo undo-stack*)
op))
(defn- pop-redo-op
[repo]
(let [redo-stack (get @*redo-ops repo)
[op redo-stack*] (pop-stack redo-stack)]
(swap! *redo-ops assoc repo redo-stack*)
op))
(defn- empty-undo-stack?
[repo]
(empty? (get @*undo-ops repo)))
(defn- empty-redo-stack?
[repo]
(empty? (get @*redo-ops repo)))
(defn- undo-redo-action-meta
[{:keys [tx-meta]
source-tx-id :db-sync/tx-id}
undo?]
(-> tx-meta
(dissoc :db-sync/tx-id)
(assoc
:gen-undo-ops? false
:persist-op? true
:undo? undo?
:redo? (not undo?)
:db-sync/source-tx-id source-tx-id)))
(defn- rebind-op-db-sync-tx-id
[op history-tx-id]
(if (uuid? history-tx-id)
(mapv (fn [item]
(if (= ::db-transact (first item))
[::db-transact (assoc (second item) :db-sync/tx-id history-tx-id)]
item))
op)
op))
(defn- skippable-worker-error?
[error]
(= :invalid-history-action-ops (:reason (ex-data error))))
(defn- skippable-worker-result?
[undo? {:keys [reason]}]
(if undo?
(contains? #{:invalid-history-action-ops
:invalid-history-action-tx
:unsupported-history-action}
reason)
(contains? #{:invalid-history-action-ops}
reason)))
(declare undo-redo-aux)
(defn- empty-stack-result
[undo?]
(if undo? ::empty-undo-stack ::empty-redo-stack))
(defn- push-opposite-op!
[repo undo? op]
(let [sanitize-db-transact
(fn [data]
;; Keep undo/redo history op-only. Drop any legacy/raw tx payloads.
(dissoc data
:tx
:tx-data
:reversed-tx
:reversed-tx-data
:db-sync/normalized-tx-data
:db-sync/reversed-tx-data))
op' (mapv (fn [item]
(if (= ::db-transact (first item))
[::db-transact (sanitize-db-transact (second item))]
item))
op)]
((if undo? push-redo-op push-undo-op) repo op')))
(defn- undo-redo-result
[repo conn undo? op op']
(push-opposite-op! repo undo? op')
(let [editor-cursors (->> (filter #(= ::record-editor-info (first %)) op)
(map second))
cursor (if undo?
(first editor-cursors)
(or (last editor-cursors) (first editor-cursors)))
block-content (when-let [block-uuid (:block-uuid cursor)]
(:block/title (d/entity @conn [:block/uuid block-uuid])))]
{:undo? undo?
:editor-cursors editor-cursors
:block-content block-content}))
(defn- skip-op-and-recur
[repo undo?]
(undo-redo-aux repo undo?))
(defn- run-worker-path
[repo conn undo? op tx-meta' tx-id]
(if-let [apply-action @*apply-history-action!]
(try
(let [worker-result (apply-action repo tx-id undo? tx-meta')]
(cond
(:applied? worker-result)
(undo-redo-result repo conn undo? op
(if undo?
op
(rebind-op-db-sync-tx-id op (:history-tx-id worker-result))))
(skippable-worker-result? undo? worker-result)
(skip-op-and-recur repo undo?)
:else
(do
(log/error ::undo-redo-worker-action-unavailable
{:undo? undo?
:repo repo
:tx-id tx-id
:result worker-result})
(clear-history! repo)
(empty-stack-result undo?))))
(catch :default e
(if (skippable-worker-error? e)
(skip-op-and-recur repo undo?)
(do
(log/error ::undo-redo-worker-failed e)
(clear-history! repo)
(throw e)
(empty-stack-result undo?)))))
(do
(log/error ::undo-redo-worker-action-unavailable
{:undo? undo?
:repo repo
:tx-id tx-id
:tx-meta tx-meta'
:reason :missing-apply-history-action})
(clear-history! repo)
(empty-stack-result undo?))))
(defn- process-db-op
[repo conn undo? op]
(when-let [data (some #(when (= ::db-transact (first %))
(second %))
op)]
(let [tx-id (:db-sync/tx-id data)
forward-outliner-ops (:db-sync/forward-outliner-ops data)
inverse-outliner-ops (:db-sync/inverse-outliner-ops data)
tx-meta' (-> (undo-redo-action-meta data undo?)
(assoc :forward-outliner-ops forward-outliner-ops
:inverse-outliner-ops inverse-outliner-ops
:db-sync/forward-outliner-ops forward-outliner-ops
:db-sync/inverse-outliner-ops inverse-outliner-ops))]
(run-worker-path repo conn undo? op tx-meta' tx-id))))
(defn- undo-redo-aux
[repo undo?]
(if-let [op (not-empty ((if undo? pop-undo-op pop-redo-op) repo))]
(if (= ::ui-state (ffirst op))
(do
(push-opposite-op! repo undo? op)
{:undo? undo?
:ui-state-str (second (first op))})
(process-db-op repo (worker-state/get-datascript-conn repo) undo? op))
(when ((if undo? empty-undo-stack? empty-redo-stack?) repo)
(empty-stack-result undo?))))
(defn undo
[repo]
(undo-redo-aux repo true))
(defn redo
[repo]
(undo-redo-aux repo false))
(defn record-editor-info!
[repo editor-info]
(when editor-info
(swap! *undo-ops
update repo
(fn [stack]
(if (seq stack)
(update stack (dec (count stack))
(fn [op]
(conj (vec op) [::record-editor-info editor-info])))
stack)))))
(defn record-ui-state!
[repo ui-state-str]
(when ui-state-str
(push-undo-op repo [[::ui-state ui-state-str]])))
(defn- pending-history-action-ops
[repo tx-id]
(when (uuid? tx-id)
(when-let [conn (get @worker-state/*client-ops-conns repo)]
(when-let [ent (d/entity @conn [:db-sync/tx-id tx-id])]
{:db-sync/forward-outliner-ops (some-> (:db-sync/forward-outliner-ops ent) seq vec)
:db-sync/inverse-outliner-ops (some-> (:db-sync/inverse-outliner-ops ent) seq vec)}))))
(defn gen-undo-ops!
[repo {:keys [tx-data tx-meta db-after db-before]} tx-id
{:keys [apply-history-action!]}]
(when (nil? @*apply-history-action!)
(reset! *apply-history-action! apply-history-action!))
(let [{:keys [outliner-op local-tx?]} tx-meta
{:db-sync/keys [forward-outliner-ops inverse-outliner-ops]} (pending-history-action-ops repo tx-id)]
(when (and
(true? local-tx?)
outliner-op
(not (false? (:gen-undo-ops? tx-meta)))
(not (:create-today-journal? tx-meta))
(seq forward-outliner-ops)
(seq inverse-outliner-ops))
(let [all-ids (distinct (map :e tx-data))
retracted-ids (set
(filter
(fn [id] (and (nil? (d/entity db-after id)) (d/entity db-before id)))
all-ids))
added-ids (set
(filter
(fn [id] (and (nil? (d/entity db-before id)) (d/entity db-after id)))
all-ids))
editor-info (or (:undo-redo/editor-info tx-meta)
(take-pending-editor-info! repo))
data (cond-> {:db-sync/tx-id tx-id
:tx-meta (dissoc tx-meta :outliner-ops)
:added-ids added-ids
:retracted-ids retracted-ids
:db-sync/forward-outliner-ops forward-outliner-ops
:db-sync/inverse-outliner-ops inverse-outliner-ops})
op (->> [(when editor-info [::record-editor-info editor-info])
[::db-transact data]]
(remove nil?)
vec)]
;; A new local action invalidates redo history.
(swap! *redo-ops assoc repo [])
(push-undo-op repo op)))))
(defn get-debug-state
[repo]
{:undo-ops (get @*undo-ops repo [])
:redo-ops (get @*redo-ops repo [])
:pending-editor-info (get @*pending-editor-info repo)})
(defn referenced-history-tx-ids
[repo]
(->> (concat (get @*undo-ops repo [])
(get @*redo-ops repo []))
(mapcat identity)
(keep (fn [item]
(when (= ::db-transact (first item))
(let [tx-id (:db-sync/tx-id (second item))]
(when (uuid? tx-id)
tx-id)))))
set))

View File

@@ -188,8 +188,7 @@
[block-uuid-or-page-name]
(p/let [repo (state/get-current-repo)
block (db-async/<get-block repo (str block-uuid-or-page-name)
{:children? true
:include-collapsed-children? true})
{:include-collapsed-children? true})
_ (when-let [page-id (:db/id (:block/page block))]
(when-let [page-uuid (:block/uuid (db/entity page-id))]
(db-async/<get-block repo page-uuid)))]
@@ -278,8 +277,7 @@
(def get_block
(fn [id ^js opts]
(p/let [block (db-async/<get-block (state/get-current-repo) id {:children? true
:include-collapsed-children? true})]
(p/let [block (db-async/<get-block (state/get-current-repo) id {:include-collapsed-children? true})]
(api-block/get_block (:db/id block) (or opts #js {:includePage true})))))
(def get_current_block

View File

@@ -1,266 +0,0 @@
(ns logseq.undo-redo-validate
"Undo redo validate"
(:require [clojure.set :as set]
[datascript.core :as d]
[logseq.db :as ldb]))
(def ^:private structural-attrs
#{:block/uuid :block/parent :block/page})
(def ^:private ref-attrs
#{:block/parent :block/page})
(def ^:private recycle-attrs
#{:logseq.property/deleted-at
:logseq.property/deleted-by-ref
:logseq.property.recycle/original-parent
:logseq.property.recycle/original-page
:logseq.property.recycle/original-order})
(defn- recycle-tx-item?
[item]
(cond
(map? item)
(some recycle-attrs (keys item))
(vector? item)
(contains? recycle-attrs (nth item 2 nil))
(d/datom? item)
(contains? recycle-attrs (:a item))
:else false))
(defn- recycle-tx?
[tx-data]
(boolean (some recycle-tx-item? tx-data)))
(defn- structural-tx-item?
[item]
(cond
(map? item)
(some structural-attrs (keys item))
(vector? item)
(let [op (first item)
a (nth item 2 nil)]
(or (= :db/retractEntity op)
(contains? structural-attrs a)))
(d/datom? item)
(contains? structural-attrs (:a item))
:else false))
(defn- structural-tx?
[tx-data]
(boolean (some structural-tx-item? tx-data)))
(defn- resolve-entity-id
[db value]
(cond
(int? value) value
(vector? value) (d/entid db value)
:else nil))
(defn- tx-entity-ids
[db tx-data]
(->> tx-data
(keep (fn [item]
(cond
(vector? item)
(let [e (second item)]
(resolve-entity-id db e))
(d/datom? item)
(resolve-entity-id db (:e item))
(map? item)
(or (resolve-entity-id db (:db/id item))
(resolve-entity-id db [:block/uuid (:block/uuid item)]))
:else nil)))
(remove nil?)
set))
(defn- entities-exist?
[db tx-data]
(every? (fn [id]
(when id
(d/entity db id)))
(tx-entity-ids db tx-data)))
(defn- entity-has-identity?
[ent]
(or (:block/uuid ent)
(:db/ident ent)))
(defn- recycle-entities-valid?
[db tx-data]
(every? (fn [id]
(when-let [ent (d/entity db id)]
(entity-has-identity? ent)))
(tx-entity-ids db tx-data)))
(defn- parent-cycle?
[ent]
(let [start (:block/uuid ent)]
(loop [current ent
seen #{start}
steps 0]
(cond
(>= steps 200) true
(nil? (:block/parent current)) false
:else (let [next-ent (:block/parent current)
next-uuid (:block/uuid next-ent)]
(if (contains? seen next-uuid)
true
(recur next-ent (conj seen next-uuid) (inc steps))))))))
(defn- issues-for-entity-ids
[db ids]
(let [id->ent (->> ids
(keep (fn [id]
(when-let [ent (d/entity db id)]
(when (:db/id ent)
[id ent]))))
(into {}))
ents (vals id->ent)
structural-ids (->> id->ent
(keep (fn [[id ent]]
(when (or (:block/title ent)
(:block/page ent)
(:block/parent ent)
(:block/order ent))
id)))
set)]
(concat
(for [e structural-ids
:let [ent (get id->ent e)]
:when (nil? (:block/uuid ent))]
{:type :missing-uuid :e e})
(for [ent ents
:let [block-uuid (:block/uuid ent)
parent (:block/parent ent)]
:when (and (contains? structural-ids (:db/id ent))
(not (ldb/page? ent))
(nil? parent))]
{:type :missing-parent :uuid block-uuid})
(for [ent ents
:let [block-uuid (:block/uuid ent)
parent (:block/parent ent)]
:when (and (contains? structural-ids (:db/id ent))
(not (ldb/page? ent))
parent
(nil? (:block/uuid parent)))]
{:type :missing-parent-ref :uuid block-uuid})
(for [ent ents
:let [block-uuid (:block/uuid ent)
page (:block/page ent)]
:when (and (contains? structural-ids (:db/id ent))
(not (ldb/page? ent))
(nil? page))]
{:type :missing-page :uuid block-uuid})
(for [ent ents
:let [block-uuid (:block/uuid ent)
page (:block/page ent)]
:when (and (contains? structural-ids (:db/id ent))
(not (ldb/page? ent))
page
(not (ldb/page? page)))]
{:type :page-not-page :uuid block-uuid})
(for [ent ents
:let [block-uuid (:block/uuid ent)
parent (:block/parent ent)
page (:block/page ent)
expected-page (when parent
(if (ldb/page? parent) parent (:block/page parent)))]
:when (and (contains? structural-ids (:db/id ent))
(not (ldb/page? ent))
parent
page
expected-page
(not= (:block/uuid expected-page) (:block/uuid page)))]
{:type :page-mismatch :uuid block-uuid})
(for [ent ents
:let [block-uuid (:block/uuid ent)
parent (:block/parent ent)]
:when (and (contains? structural-ids (:db/id ent))
parent
(= block-uuid (:block/uuid parent)))]
{:type :self-parent :uuid block-uuid})
(for [ent ents
:let [block-uuid (:block/uuid ent)]
:when (and (contains? structural-ids (:db/id ent))
(not (ldb/page? ent))
(parent-cycle? ent))]
{:type :cycle :uuid block-uuid}))))
(defn- retract-entity-ids
[db-before tx-data]
(->> tx-data
(keep (fn [item]
(when (and (vector? item) (= :db/retractEntity (first item)))
(second item))))
(map (fn [id] (d/entid db-before id)))
(filter int?)))
(defn- affected-entity-ids
[db-before {:keys [tx-data]} original-tx-data]
(let [tx-ids (->> tx-data
(mapcat (fn [[e a v _tx _added]]
(cond-> #{e}
(and (contains? ref-attrs a) (int? v)) (conj v)
(and (contains? ref-attrs a) (vector? v))
(conj (d/entid db-before v)))))
(remove nil?)
set)
retract-ids (retract-entity-ids db-before original-tx-data)
child-ids (mapcat (fn [id]
(when-let [ent (d/entity db-before id)]
(map :db/id (:block/_parent ent))))
retract-ids)]
(-> tx-ids
(into retract-ids)
(into child-ids))))
(defn- warn-invalid!
[data]
(js/console.warn "undo-redo-invalid" (clj->js data)))
(defn- log-validate-error!
[error]
(js/console.error "undo-redo-validate-failed" error))
(defn valid-undo-redo-tx?
[conn tx-data]
(try
(if (recycle-tx? tx-data)
(if (recycle-entities-valid? @conn tx-data)
true
(do
(warn-invalid! {:reason :invalid-recycle-entities})
false))
(if-not (structural-tx? tx-data)
(if (entities-exist? @conn tx-data)
true
(do
(warn-invalid! {:reason :missing-entities})
false))
(let [db-before @conn
tx-report (d/with db-before tx-data)
db-after (:db-after tx-report)
affected-ids (affected-entity-ids db-before tx-report tx-data)
baseline-issues (if (seq affected-ids)
(set (issues-for-entity-ids db-before affected-ids))
#{})
after-issues (if (seq affected-ids)
(set (issues-for-entity-ids db-after affected-ids))
#{})
new-issues (seq (set/difference after-issues baseline-issues))]
(when (seq new-issues)
(warn-invalid! {:issues (take 5 new-issues)}))
(empty? new-issues))))
(catch :default e
(log-validate-error! e)
false)))

View File

@@ -580,6 +580,7 @@
:dev/show-page-data "(Dev) Show page data"
:dev/replace-graph-with-db-file "(Dev) Replace graph with its db.sqlite file"
:dev/validate-db "(Dev) Validate current graph"
:dev/recompute-checksum "(Dev) Recompute graph checksum"
:dev/gc-graph "(Dev) Garbage collect graph (remove unused data in SQLite)"
:dev/rtc-stop "(Dev) RTC Stop"
:dev/rtc-start "(Dev) RTC Start"

View File

@@ -87,12 +87,16 @@
(deftest rtc-create-graph-persists-disabled-e2ee-flag-test
(async done
(let [fetch-called (atom nil)
tx-called (atom nil)]
tx-called (atom nil)
ensure-calls (atom [])]
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
db/get-db (fn [] :db)
ldb/get-graph-schema-version (fn [_] {:major 65})
state/<invoke-db-worker (fn [& args]
(swap! ensure-calls conj args)
(p/resolved {:public-key "pk"}))
db-sync/fetch-json (fn [url opts _]
(reset! fetch-called {:url url :opts opts})
(p/resolved {:graph-id "graph-1"
@@ -110,6 +114,9 @@
(is (= "graph-1" graph-id))
(is (= "http://base/graphs" (:url @fetch-called)))
(is (= false (:graph-e2ee? request-body)))
(is (= [[:thread-api/db-sync-ensure-user-rsa-keys
{:ensure-server? true}]]
@ensure-calls))
(is (= :logseq.kv/graph-rtc-e2ee?
(get-in tx-data [2 :db/ident])))
(is (= false
@@ -122,12 +129,16 @@
(deftest rtc-create-graph-defaults-e2ee-enabled-test
(async done
(let [fetch-called (atom nil)
tx-called (atom nil)]
tx-called (atom nil)
ensure-calls (atom [])]
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
db/get-db (fn [] :db)
ldb/get-graph-schema-version (fn [_] {:major 65})
state/<invoke-db-worker (fn [& args]
(swap! ensure-calls conj args)
(p/resolved {:public-key "pk"}))
db-sync/fetch-json (fn [url opts _]
(reset! fetch-called {:url url :opts opts})
(p/resolved {:graph-id "graph-2"}))
@@ -145,6 +156,9 @@
(is (= "http://base/graphs" (:url @fetch-called)))
(is (= true (:graph-e2ee? request-body)))
(is (= true (:graph-ready-for-use? request-body)))
(is (= [[:thread-api/db-sync-ensure-user-rsa-keys
{:ensure-server? true}]]
@ensure-calls))
(is (= :logseq.kv/graph-rtc-e2ee?
(get-in tx-data [2 :db/ident])))
(is (= true
@@ -189,7 +203,9 @@
js/JSON.parse
(js->clj :keywordize-keys true))]
(is (= false (:graph-ready-for-use? request-body)))
(is (= [[:thread-api/db-sync-upload-graph "logseq_db_demo"]]
(is (= [[:thread-api/db-sync-ensure-user-rsa-keys
{:ensure-server? true}]
[:thread-api/db-sync-upload-graph "logseq_db_demo"]]
@upload-calls))
(is (= 1 @refresh-calls))
(is (= ["logseq_db_demo"] @start-calls))
@@ -309,7 +325,10 @@
(deftest get-remote-graphs-includes-ready-for-use-flag-test
(async done
(let [graphs-state (atom nil)]
(let [graphs-state (atom nil)
worker-prev @state/*db-worker
ensure-calls (atom [])]
(reset! state/*db-worker :worker)
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
@@ -320,7 +339,11 @@
:graph-e2ee? true
:graph-ready-for-use? false
:created-at 1
:updated-at 2}]}))
:updated-at 2}]
:user-rsa-keys-exists? true}))
state/<invoke-db-worker (fn [& args]
(swap! ensure-calls conj args)
(p/resolved :ok))
state/set-state! (fn [k v]
(when (= k :rtc/graphs)
(reset! graphs-state v))
@@ -330,10 +353,42 @@
(p/then (fn [graphs]
(is (= false (:graph-ready-for-use? (first graphs))))
(is (= false (:graph-ready-for-use? (first @graphs-state))))
(is (empty? @ensure-calls))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(done)))
(p/finally (fn []
(reset! state/*db-worker worker-prev)))))))
(deftest get-remote-graphs-ensures-user-rsa-keys-when-server-missing-test
(async done
(let [worker-prev @state/*db-worker
ensure-calls (atom [])]
(reset! state/*db-worker :worker)
(-> (p/with-redefs [db-sync/http-base (fn [] "http://base")
user-handler/task--ensure-id&access-token (fn [resolve _reject]
(resolve true))
db-sync/fetch-json (fn [_url _opts _schema]
(p/resolved {:graphs []
:user-rsa-keys-exists? false}))
state/<invoke-db-worker (fn [& args]
(swap! ensure-calls conj args)
(p/resolved {:public-key "pk"}))
state/set-state! (fn [& _] nil)
repo-handler/refresh-repos! (fn [] nil)]
(db-sync/<get-remote-graphs))
(p/then (fn [_]
(is (= [[:thread-api/db-sync-ensure-user-rsa-keys
{:ensure-server? true
:server-rsa-keys-exists? false}]]
@ensure-calls))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))
(p/finally (fn []
(reset! state/*db-worker worker-prev)))))))
(deftest rtc-download-graph-imports-snapshot-once-test
(async done

View File

@@ -74,14 +74,13 @@
[(missing? $ ?b :logseq.property/deleted-at)]]
@conn)
(map (comp :block/title first)))
recycled-blocks (->> (d/q '[:find (pull ?b [*])
:where
[?b :logseq.property/deleted-at]
[?b :block/title ""]]
@conn)
(map first))]
deleted-blocks (->> (d/q '[:find (pull ?b [*])
:where
[?b :block/title ""]]
@conn)
(map first))]
(is (= ["b1" "b2"] updated-blocks) "Visible page blocks stay on the page")
(is (= 1 (count recycled-blocks)) "Deleted block moves to recycle")))})))
(is (empty? deleted-blocks) "Deleted block is removed from page db")))})))
(testing "backspace deletes empty block in embedded context"
;; testing embed at this layer doesn't require an embed block since
@@ -104,11 +103,10 @@
[(missing? $ ?b :logseq.property/deleted-at)]]
@conn)
(map (comp :block/title first)))
recycled-blocks (->> (d/q '[:find (pull ?b [*])
:where
[?b :logseq.property/deleted-at]
[?b :block/title ""]]
@conn)
(map first))]
deleted-blocks (->> (d/q '[:find (pull ?b [*])
:where
[?b :block/title ""]]
@conn)
(map first))]
(is (= ["b1" "b2"] updated-blocks) "Visible page blocks stay on the page")
(is (= 1 (count recycled-blocks)) "Deleted block moves to recycle")))}))))
(is (empty? deleted-blocks) "Deleted block is removed from page db")))}))))

View File

@@ -0,0 +1,107 @@
(ns frontend.handler.history-test
(:require [clojure.test :refer [deftest is]]
[frontend.db :as db]
[frontend.handler.editor :as editor]
[frontend.handler.history :as history]
[frontend.state :as state]
[frontend.util :as util]
[logseq.db :as ldb]))
(deftest restore-cursor-and-state-prefers-ui-state-test
(let [pause-calls (atom [])
app-state-calls (atom [])
cursor-calls (atom [])]
(with-redefs [state/set-state! (fn [k v]
(swap! pause-calls conj [k v]))
ldb/read-transit-str (fn [_]
{:old-state {:route-data {:to :page}}
:new-state {:route-data {:to :home}}})
history/restore-app-state! (fn [app-state]
(swap! app-state-calls conj app-state))
history/restore-cursor! (fn [data]
(swap! cursor-calls conj data))]
(#'history/restore-cursor-and-state!
{:ui-state-str "ui-state"
:undo? true
:editor-cursors [{:block-uuid (random-uuid)}]})
(is (= [[:history/paused? true]
[:history/paused? false]]
@pause-calls))
(is (= [{:route-data {:to :page}}]
@app-state-calls))
(is (empty? @cursor-calls)))))
(deftest restore-cursor-and-state-falls-back-to-cursor-test
(let [pause-calls (atom [])
app-state-calls (atom [])
cursor-calls (atom [])]
(with-redefs [state/set-state! (fn [k v]
(swap! pause-calls conj [k v]))
history/restore-app-state! (fn [app-state]
(swap! app-state-calls conj app-state))
history/restore-cursor! (fn [data]
(swap! cursor-calls conj data))]
(#'history/restore-cursor-and-state!
{:ui-state-str nil
:undo? false
:editor-cursors [{:block-uuid (random-uuid)
:start-pos 1
:end-pos 2}]})
(is (= [[:history/paused? true]
[:history/paused? false]]
@pause-calls))
(is (empty? @app-state-calls))
(is (= 1 (count @cursor-calls)))
(is (nil? (:ui-state-str (first @cursor-calls))))
(is (= false (:undo? (first @cursor-calls)))))))
(deftest restore-cursor-prefers-block-selection-test
(let [selection-calls (atom [])
edit-calls (atom [])]
(with-redefs [util/get-blocks-by-id (fn [block-id]
(case block-id
#uuid "00000000-0000-0000-0000-000000000001" [:node-1]
#uuid "00000000-0000-0000-0000-000000000002" [:node-2]
nil))
state/exit-editing-and-set-selected-blocks! (fn [blocks direction]
(swap! selection-calls conj [blocks direction]))
editor/edit-block! (fn [& args]
(swap! edit-calls conj args))
db/pull (constantly nil)]
(#'history/restore-cursor!
{:undo? true
:editor-cursors [{:selected-block-uuids [#uuid "00000000-0000-0000-0000-000000000001"
#uuid "00000000-0000-0000-0000-000000000002"]
:selection-direction :down}]})
(is (= [[[:node-1 :node-2] :down]]
@selection-calls))
(is (empty? @edit-calls)))))
(deftest restore-cursor-selection-falls-back-to-editor-cursor-test
(let [selection-calls (atom [])
edit-calls (atom [])
block-uuid #uuid "00000000-0000-0000-0000-000000000003"]
(with-redefs [util/get-blocks-by-id (constantly nil)
state/exit-editing-and-set-selected-blocks! (fn [blocks direction]
(swap! selection-calls conj [blocks direction]))
editor/edit-block! (fn [& args]
(swap! edit-calls conj args))
db/pull (fn [[_lookup-k id]]
(when (= block-uuid id)
{:db/id 42
:block/uuid block-uuid}))]
(#'history/restore-cursor!
{:undo? false
:editor-cursors [{:selected-block-uuids [#uuid "00000000-0000-0000-0000-000000000001"]
:selection-direction :up
:block-uuid block-uuid
:container-id 99
:start-pos 1
:end-pos 3}]})
(is (empty? @selection-calls))
(is (= [[{:db/id 42
:block/uuid block-uuid}
3
{:container-id 99
:custom-content nil}]]
@edit-calls)))))

View File

@@ -0,0 +1,70 @@
(ns frontend.handler.user-test
(:require [cljs.test :refer [deftest is testing]]
[frontend.handler.user :as user-handler]
[frontend.state :as state]
[promesa.core :as p]))
(defn- with-mocked-local-storage
[f]
(let [old-storage (.-localStorage js/globalThis)
had-local-storage?
(.call (.-hasOwnProperty (.-prototype js/Object))
js/globalThis
"localStorage")
mocked-storage #js {:clear (fn [] nil)
:setItem (fn [& _] nil)
:getItem (fn [& _] nil)
:removeItem (fn [& _] nil)}]
(js/Object.defineProperty js/globalThis
"localStorage"
#js {:value mocked-storage
:configurable true
:writable true})
(try
(f)
(finally
(if had-local-storage?
(js/Object.defineProperty js/globalThis
"localStorage"
#js {:value old-storage
:configurable true
:writable true})
(js/Reflect.deleteProperty js/globalThis "localStorage"))))))
(deftest logout-clears-e2ee-password-when-db-worker-ready-test
(testing "logout should request db-worker to clear persisted e2ee password"
(let [ops* (atom [])
old-worker @state/*db-worker]
(reset! state/*db-worker :worker)
(try
(with-mocked-local-storage
(fn []
(with-redefs [state/<invoke-db-worker (fn [op & _]
(swap! ops* conj op)
(p/resolved nil))
state/clear-user-info! (fn [] nil)
state/pub-event! (fn [& _] nil)
user-handler/clear-tokens (fn [] nil)]
(user-handler/logout)
(is (= [:thread-api/clear-e2ee-password] @ops*)))))
(finally
(reset! state/*db-worker old-worker))))))
(deftest logout-skips-e2ee-password-clear-when-db-worker-missing-test
(testing "logout should not call db-worker API when db-worker is unavailable"
(let [invoke-calls* (atom 0)
old-worker @state/*db-worker]
(reset! state/*db-worker nil)
(try
(with-mocked-local-storage
(fn []
(with-redefs [state/<invoke-db-worker (fn [& _]
(swap! invoke-calls* inc)
(p/resolved nil))
state/clear-user-info! (fn [] nil)
state/pub-event! (fn [& _] nil)
user-handler/clear-tokens (fn [] nil)]
(user-handler/logout)
(is (zero? @invoke-calls*)))))
(finally
(reset! state/*db-worker old-worker))))))

View File

@@ -40,7 +40,9 @@
#(test-helper/start-and-destroy-db
%
{:build-init-data? false
:schema {:logseq.property/deleted-at {:db/index true}}})
:schema {:logseq.property/deleted-at {:db/index true}
:logseq.property/created-from-property {:db/index true}
}})
listen-db-fixture)
(defn get-block
@@ -530,11 +532,7 @@
(is (= [5] (get-children 4)))
(let [recycled (get-block 3)
recycle-page (db/get-page "Recycle")]
(is (some? recycled))
(is (integer? (:logseq.property/deleted-at recycled)))
(is (= (:db/id recycle-page) (:db/id (:block/page recycled)))))))))
(is (nil? (get-block 3)))))))
(deftest test-bocks-with-level
(testing "blocks with level"

View File

@@ -24,3 +24,18 @@
{:shortcuts {:ui/toggle-brackets "t b"}}
{:shortcuts {:editor/up ["ctrl+p" "up"]}}))
"Map values get merged across configs"))
(deftest get-editor-info-includes-selection-when-not-editing-test
(let [selected-ids [(random-uuid) (random-uuid)]]
(with-redefs [state/get-edit-block (constantly nil)
state/get-selection-block-ids (constantly selected-ids)
state/get-selection-direction (constantly :down)]
(is (= {:selected-block-uuids selected-ids
:selection-direction :down}
(state/get-editor-info))))))
(deftest get-editor-info-returns-nil-when-not-editing-and-no-selection-test
(with-redefs [state/get-edit-block (constantly nil)
state/get-selection-block-ids (constantly nil)
state/get-selection-direction (constantly nil)]
(is (nil? (state/get-editor-info)))))

View File

@@ -1,672 +1,74 @@
(ns frontend.undo-redo-test
(:require [clojure.test :as t :refer [deftest is testing use-fixtures]]
[datascript.core :as d]
[frontend.db :as db]
[frontend.handler.editor :as editor]
[frontend.modules.outliner.core-test :as outliner-test]
(:require [clojure.test :refer [deftest is]]
[frontend.state :as state]
[frontend.test.helper :as test-helper]
[frontend.undo-redo :as undo-redo]
[frontend.worker.db-listener :as worker-db-listener]
[logseq.db :as ldb]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.op :as outliner-op]
[logseq.undo-redo-validate :as undo-validate]))
[frontend.util :as util]))
;; TODO: random property ops test
;; ADR 0013 note: this namespace keeps main-thread coordination coverage only.
;; Worker-owned DB-history recording/replay tests belong under src/test/frontend/worker/.
(def test-db test-helper/test-db)
(deftest undo-redo-proxy-to-worker-test
(let [calls (atom [])
invoke! (fn [& args]
(swap! calls conj (vec args))
(vec args))
repo "repo-1"]
(with-redefs [util/node-test? false
state/<invoke-db-worker invoke!]
(is (= [:thread-api/undo-redo-undo repo]
(undo-redo/undo repo)))
(is (= [:thread-api/undo-redo-redo repo]
(undo-redo/redo repo)))
(is (= [[:thread-api/undo-redo-undo repo]
[:thread-api/undo-redo-redo repo]]
@calls)))))
(defmethod worker-db-listener/listen-db-changes :gen-undo-ops
[_ {:keys [repo]} tx-report]
(undo-redo/gen-undo-ops! repo
(-> tx-report
(assoc-in [:tx-meta :client-id] (:client-id @state/state))
(update-in [:tx-meta :local-tx?] (fn [local-tx?]
(if (nil? local-tx?)
true
local-tx?))))))
(deftest clear-history-and-record-editor-info-proxy-test
(let [calls (atom [])
invoke! (fn [& args]
(swap! calls conj (vec args))
(vec args))
repo "repo-2"
editor-info {:block-uuid (random-uuid)
:container-id 1
:start-pos 0
:end-pos 3}]
(with-redefs [util/node-test? false
state/<invoke-db-worker invoke!]
(is (= [:thread-api/undo-redo-clear-history repo]
(undo-redo/clear-history! repo)))
(is (= [:thread-api/undo-redo-record-editor-info repo editor-info]
(undo-redo/record-editor-info! repo editor-info)))
(is (= [[:thread-api/undo-redo-clear-history repo]
[:thread-api/undo-redo-record-editor-info repo editor-info]]
@calls)))))
(defn listen-db-fixture
[f]
(let [test-db-conn (db/get-db test-db false)]
(assert (some? test-db-conn))
(worker-db-listener/listen-db-changes! test-db test-db-conn
{:handler-keys [:gen-undo-ops]})
(f)
(d/unlisten! test-db-conn :frontend.worker.db-listener/listen-db-changes!)))
(deftest record-ui-state-proxy-test
(let [calls (atom [])
invoke! (fn [& args]
(swap! calls conj (vec args))
(vec args))
repo "repo-3"
ui-state-str "{:old-state {}, :new-state {:route-data {:to :page}}}"]
(with-redefs [util/node-test? false
state/<invoke-db-worker invoke!]
(is (nil? (undo-redo/record-ui-state! repo nil)))
(is (= [:thread-api/undo-redo-record-ui-state repo ui-state-str]
(undo-redo/record-ui-state! repo ui-state-str)))
(is (= [[:thread-api/undo-redo-record-ui-state repo ui-state-str]]
@calls)))))
(defn disable-browser-fns
[f]
;; get-selection-blocks has a js/document reference
(with-redefs [state/get-selection-blocks (constantly [])]
(f)))
(defn with-worker-undo-validation
[f]
(let [orig-transact ldb/transact!]
(with-redefs [ldb/transact! (fn [repo-or-conn tx-data tx-meta]
(if (and (or (:undo? tx-meta) (:redo? tx-meta))
(not (undo-validate/valid-undo-redo-tx? repo-or-conn tx-data)))
(throw (ex-info "undo/redo tx invalid"
{:undo? (:undo? tx-meta)
:redo? (:redo? tx-meta)}))
(if (satisfies? IDeref repo-or-conn)
(d/transact! repo-or-conn tx-data tx-meta)
(orig-transact repo-or-conn tx-data tx-meta))))]
(f))))
(use-fixtures :each
disable-browser-fns
with-worker-undo-validation
test-helper/react-components
#(test-helper/start-and-destroy-db % {:build-init-data? false
:schema {:logseq.property/deleted-at {:db/index true}}})
listen-db-fixture)
(defn- undo-all!
[]
(loop [i 0]
(let [r (undo-redo/undo test-db)]
(if (not= :frontend.undo-redo/empty-undo-stack r)
(recur (inc i))
(prn :undo-count i)))))
(defn- redo-all!
[]
(loop [i 0]
(let [r (undo-redo/redo test-db)]
(if (not= :frontend.undo-redo/empty-redo-stack r)
(recur (inc i))
(prn :redo-count i)))))
(defn- parent-cycle?
[ent]
(let [start (:block/uuid ent)]
(loop [current ent
seen #{start}
steps 0]
(cond
(>= steps 200) true
(nil? (:block/parent current)) false
:else (let [next-ent (:block/parent current)
next-uuid (:block/uuid next-ent)]
(if (contains? seen next-uuid)
true
(recur next-ent (conj seen next-uuid) (inc steps))))))))
(defn- db-issues
[db]
(let [ignore-ent? (fn [ent]
(or (ldb/recycled? ent)
(= "Recycle" (:block/title ent))
(= "Recycle" (some-> ent :block/page :block/title))))
ents (->> (d/q '[:find [?e ...]
:where
[?e :block/uuid]]
db)
(map (fn [e] (d/entity db e)))
(remove ignore-ent?))
uuid-required-ids (->> (concat
(d/q '[:find [?e ...]
:where
[?e :block/title]]
db)
(d/q '[:find [?e ...]
:where
[?e :block/page]]
db)
(d/q '[:find [?e ...]
:where
[?e :block/parent]]
db))
distinct)]
(concat
(for [e uuid-required-ids
:let [ent (d/entity db e)]
:when (and (not (ignore-ent? ent))
(nil? (:block/uuid ent)))]
{:type :missing-uuid :e e})
(for [ent ents
:let [uuid (:block/uuid ent)
parent (:block/parent ent)]
:when (and (not (ldb/page? ent)) (nil? parent))]
{:type :missing-parent :uuid uuid})
(for [ent ents
:let [uuid (:block/uuid ent)
parent (:block/parent ent)]
:when (and (not (ldb/page? ent)) parent (nil? (:block/uuid parent)))]
{:type :missing-parent-ref :uuid uuid})
(for [ent ents
:let [uuid (:block/uuid ent)
page (:block/page ent)]
:when (and (not (ldb/page? ent)) (nil? page))]
{:type :missing-page :uuid uuid})
(for [ent ents
:let [uuid (:block/uuid ent)
page (:block/page ent)]
:when (and (not (ldb/page? ent)) page (not (ldb/page? page)))]
{:type :page-not-page :uuid uuid})
(for [ent ents
:let [uuid (:block/uuid ent)
parent (:block/parent ent)
page (:block/page ent)
expected-page (when parent
(if (ldb/page? parent) parent (:block/page parent)))]
:when (and (not (ldb/page? ent))
parent
page
expected-page
(not= (:block/uuid expected-page) (:block/uuid page)))]
{:type :page-mismatch :uuid uuid})
(for [ent ents
:let [uuid (:block/uuid ent)
parent (:block/parent ent)]
:when (and parent (= uuid (:block/uuid parent)))]
{:type :self-parent :uuid uuid})
(for [ent ents
:let [uuid (:block/uuid ent)]
:when (and (not (ldb/page? ent))
(parent-cycle? ent))]
{:type :cycle :uuid uuid}))))
(defn- seed-page-parent-child!
[]
(let [conn (db/get-db test-db false)
page-uuid (random-uuid)
parent-uuid (random-uuid)
child-uuid (random-uuid)]
(d/transact! conn
[{:db/ident :logseq.class/Page}
{:block/uuid page-uuid
:block/name "page"
:block/title "page"
:block/tags #{:logseq.class/Page}}
{:block/uuid parent-uuid
:block/title "parent"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}
{:block/uuid child-uuid
:block/title "child"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid parent-uuid]}]
{:outliner-op :insert-blocks
:local-tx? false})
{:page-uuid page-uuid
:parent-uuid parent-uuid
:child-uuid child-uuid}))
(defn- seed-page-two-parents-child!
[]
(let [conn (db/get-db test-db false)
page-uuid (random-uuid)
parent-a-uuid (random-uuid)
parent-b-uuid (random-uuid)
child-uuid (random-uuid)]
(d/transact! conn
[{:db/ident :logseq.class/Page}
{:block/uuid page-uuid
:block/name "page"
:block/title "page"
:block/tags #{:logseq.class/Page}}
{:block/uuid parent-a-uuid
:block/title "parent-a"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}
{:block/uuid parent-b-uuid
:block/title "parent-b"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}
{:block/uuid child-uuid
:block/title "child"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid parent-a-uuid]}]
{:outliner-op :insert-blocks
:local-tx? false})
{:page-uuid page-uuid
:parent-a-uuid parent-a-uuid
:parent-b-uuid parent-b-uuid
:child-uuid child-uuid}))
(deftest undo-records-only-local-txs-test
(testing "undo history records only local txs"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "local-update"]]
{:outliner-op :save-block
:local-tx? true})
(let [undo-result (undo-redo/undo test-db)]
(is (not= :frontend.undo-redo/empty-undo-stack undo-result))
(undo-redo/redo test-db)))
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "remote-update"]]
{:outliner-op :save-block
:local-tx? false})
(is (= :frontend.undo-redo/empty-undo-stack (undo-redo/undo test-db))))))
(deftest single-op-apply-ops-preserves-local-tx-and-client-id-test
(testing "single local outliner ops should reach listeners with local/client metadata intact"
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)
tx-meta* (atom nil)]
(d/listen! conn ::capture-tx-meta
(fn [{:keys [tx-meta]}]
(reset! tx-meta* tx-meta)))
(try
(outliner-op/apply-ops! conn
[[:save-block [{:block/uuid child-uuid
:block/title "single-op-save"} {}]]]
{:client-id (:client-id @state/state)
:local-tx? true})
(is (= true (:local-tx? @tx-meta*)))
(is (= (:client-id @state/state) (:client-id @tx-meta*)))
(finally
(d/unlisten! conn ::capture-tx-meta))))))
(deftest undo-conflict-clears-history-test
(testing "undo clears history when reverse tx is unsafe"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
block-uuid (random-uuid)]
(d/transact! conn [{:block/uuid block-uuid
:block/title "conflict"}]
{:outliner-op :insert-blocks
:local-tx? true})
(with-redefs [undo-redo/get-reversed-datoms (fn [& _] nil)]
(is (= :frontend.undo-redo/empty-undo-stack (undo-redo/undo test-db)))))))
(deftest undo-works-for-local-graph-test
(testing "undo/redo works for local changes on local graph"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "local-1"]]
{:outliner-op :save-block
:local-tx? true})
(let [undo-result (undo-redo/undo test-db)]
(is (not= :frontend.undo-redo/empty-undo-stack undo-result))
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid])))))
(let [redo-result (undo-redo/redo test-db)]
(is (not= :frontend.undo-redo/empty-redo-stack redo-result))
(is (= "local-1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(deftest undo-insert-retracts-added-entity-cleanly-test
(testing "undoing a local insert retracts the inserted entity instead of leaving a partial shell"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [page-uuid]} (seed-page-parent-child!)
inserted-uuid (random-uuid)]
(d/transact! conn
[{:block/uuid inserted-uuid
:block/title "inserted"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}]
{:outliner-op :insert-blocks
:local-tx? true})
(is (some? (d/entity @conn [:block/uuid inserted-uuid])))
(let [undo-result (undo-redo/undo test-db)]
(is (not= :frontend.undo-redo/empty-undo-stack undo-result))
(is (nil? (d/entity @conn [:block/uuid inserted-uuid])))))))
(deftest repeated-save-block-content-undo-redo-test
(testing "multiple saves on the same block undo and redo one step at a time"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(doseq [title ["v1" "v2" "v3"]]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title title]]
{:outliner-op :save-block
:local-tx? true}))
(is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/undo test-db)
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/undo test-db)
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/undo test-db)
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/redo test-db)
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/redo test-db)
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/redo test-db)
(is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))
(deftest repeated-editor-save-block-content-undo-redo-test
(testing "editor/save-block! records sequential content saves in order"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(doseq [title ["foo" "foo bar"]]
(editor/save-block! test-db child-uuid title))
(is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/undo test-db)
(is (= "foo" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/redo test-db)
(is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))
(deftest editor-save-two-blocks-undo-targets-latest-block-test
(testing "undo after saving two different blocks reverts the latest saved block first"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [parent-uuid child-uuid]} (seed-page-parent-child!)]
(editor/save-block! test-db parent-uuid "parent updated")
(editor/save-block! test-db child-uuid "child updated")
(undo-redo/undo test-db)
(is (= "parent updated" (:block/title (d/entity @conn [:block/uuid parent-uuid]))))
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(undo-redo/undo test-db)
(is (= "parent" (:block/title (d/entity @conn [:block/uuid parent-uuid])))))))
(deftest new-local-save-clears-redo-stack-test
(testing "a new local save clears redo history"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(editor/save-block! test-db child-uuid "v1")
(editor/save-block! test-db child-uuid "v2")
(undo-redo/undo test-db)
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(editor/save-block! test-db child-uuid "v3")
(is (= :frontend.undo-redo/empty-redo-stack (undo-redo/redo test-db)))
(is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))
(deftest insert-save-delete-sequence-undo-redo-test
(testing "insert then save then recycle-delete can be undone and redone in order"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [page-uuid]} (seed-page-parent-child!)
inserted-uuid (random-uuid)
recycle-title "Recycle"]
(d/transact! conn
[{:block/uuid inserted-uuid
:block/title "draft"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}]
{:outliner-op :insert-blocks
:local-tx? true})
(d/transact! conn
[[:db/add [:block/uuid inserted-uuid] :block/title "published"]]
{:outliner-op :save-block
:local-tx? true})
(outliner-core/delete-blocks! conn [(d/entity @conn [:block/uuid inserted-uuid])] {})
(is (= recycle-title
(:block/title (:block/page (d/entity @conn [:block/uuid inserted-uuid])))))
(undo-redo/undo test-db)
(let [restored (d/entity @conn [:block/uuid inserted-uuid])]
(is (= page-uuid (:block/uuid (:block/page restored))))
(is (= "published" (:block/title restored))))
(undo-redo/undo test-db)
(is (= "draft" (:block/title (d/entity @conn [:block/uuid inserted-uuid]))))
(undo-redo/undo test-db)
(is (nil? (d/entity @conn [:block/uuid inserted-uuid])))
(undo-redo/redo test-db)
(is (= "draft" (:block/title (d/entity @conn [:block/uuid inserted-uuid]))))
(undo-redo/redo test-db)
(is (= "published" (:block/title (d/entity @conn [:block/uuid inserted-uuid]))))
(undo-redo/redo test-db)
(is (= recycle-title
(:block/title (:block/page (d/entity @conn [:block/uuid inserted-uuid]))))))))
(deftest undo-works-with-remote-updates-test
(testing "undo works after remote updates on sync graphs"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "local-2"]]
{:outliner-op :save-block
:local-tx? true})
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/updated-at 12345]]
{:outliner-op :save-block
:local-tx? false})
(let [undo-result (undo-redo/undo test-db)]
(is (not= :frontend.undo-redo/empty-undo-stack undo-result))
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(deftest undo-redo-works-for-recycle-delete-test
(testing "undo restores a recycled delete and redo sends it back to recycle"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid page-uuid]} (seed-page-parent-child!)
recycle-page-title "Recycle"]
(outliner-core/delete-blocks! conn [(d/entity @conn [:block/uuid child-uuid])] {})
(let [deleted-child (d/entity @conn [:block/uuid child-uuid])]
(is (integer? (:logseq.property/deleted-at deleted-child)))
(is (= recycle-page-title (:block/title (:block/page deleted-child)))))
(let [undo-result (undo-redo/undo test-db)
restored-child (d/entity @conn [:block/uuid child-uuid])]
(is (not= :frontend.undo-redo/empty-undo-stack undo-result))
(is (= page-uuid (:block/uuid (:block/page restored-child))))
(is (nil? (:logseq.property/deleted-at restored-child))))
(let [redo-result (undo-redo/redo test-db)
recycled-child (d/entity @conn [:block/uuid child-uuid])]
(is (not= :frontend.undo-redo/empty-redo-stack redo-result))
(is (= recycle-page-title (:block/title (:block/page recycled-child))))
(is (integer? (:logseq.property/deleted-at recycled-child)))))))
(deftest undo-validation-allows-baseline-issues-test
(testing "undo validation allows existing issues without introducing new ones"
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)
orphan-uuid (random-uuid)]
(d/transact! conn
[{:block/uuid orphan-uuid
:block/title "orphan"}]
{:local-tx? false})
(is (undo-validate/valid-undo-redo-tx? conn
[[:db/add [:block/uuid child-uuid]
:block/title "child-updated"]])))))
(deftest undo-validation-rejects-invalid-recycle-restore-tx-test
(testing "recycle-shaped undo tx still validates resulting structure"
(let [conn (db/get-db test-db false)
page-uuid (random-uuid)
block-uuid (random-uuid)]
(d/transact! conn
[{:db/ident :logseq.class/Page}
{:block/uuid page-uuid
:block/name "page"
:block/title "page"
:block/tags #{:logseq.class/Page}}
{:db/id 1000
:block/uuid block-uuid
:block/title "bad-block"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]
:logseq.property/deleted-at 1
:logseq.property.recycle/original-page [:block/uuid page-uuid]
:logseq.property.recycle/original-parent [:block/uuid page-uuid]
:logseq.property.recycle/original-order "aj"}]
{:local-tx? false})
;; Simulate a broken recycled block shell like the runtime repro: entity has
;; structural attrs but no title/uuid dispatch attrs after sync churn.
(d/transact! conn
[[:db/retract 1000 :block/uuid block-uuid]
[:db/retract 1000 :block/title "bad-block"]]
{:local-tx? false})
(is (false? (undo-validate/valid-undo-redo-tx?
conn
[[:db/retract 1000 :logseq.property.recycle/original-order "aj"]
[:db/retract 1000 :logseq.property/deleted-at 1]
[:db/add 1000 :block/parent [:block/uuid page-uuid]]
[:db/retract 1000 :logseq.property.recycle/original-page [:block/uuid page-uuid]]
[:db/retract 1000 :logseq.property.recycle/original-parent [:block/uuid page-uuid]]
[:db/add 1000 :block/order "aj"]
[:db/add 1000 :block/page [:block/uuid page-uuid]]]))))))
(deftest undo-skips-when-parent-missing-test
(testing "undo skips when parent is missing"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [parent-uuid child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/retractEntity [:block/uuid child-uuid]]]
{:outliner-op :delete-blocks
:local-tx? true})
(d/transact! conn
[[:db/retractEntity [:block/uuid parent-uuid]]]
{:outliner-op :delete-blocks
:local-tx? false})
(is (= :frontend.undo-redo/empty-undo-stack (undo-redo/undo test-db)))
(is (nil? (d/entity @conn [:block/uuid child-uuid]))))))
(deftest undo-skips-when-block-deleted-remote-test
(testing "undo skips when block was deleted remotely"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "child-updated"]]
{:outliner-op :save-block
:local-tx? true})
(d/transact! conn
[[:db/retractEntity [:block/uuid child-uuid]]]
{:outliner-op :delete-blocks
:local-tx? false})
(is (= :frontend.undo-redo/empty-undo-stack (undo-redo/undo test-db)))
(is (nil? (d/entity @conn [:block/uuid child-uuid]))))))
(deftest undo-skips-when-undo-would-create-cycle-test
(testing "undo skips when it would create a parent cycle"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [page-uuid parent-uuid child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/parent [:block/uuid page-uuid]]]
{:outliner-op :move-blocks
:local-tx? true})
(d/transact! conn
[[:db/add [:block/uuid parent-uuid] :block/parent [:block/uuid child-uuid]]]
{:outliner-op :move-blocks
:local-tx? false})
(is (= :frontend.undo-redo/empty-undo-stack (undo-redo/undo test-db)))
(let [parent (d/entity @conn [:block/uuid parent-uuid])
child (d/entity @conn [:block/uuid child-uuid])]
(is (= child-uuid (:block/uuid (:block/parent parent))))
(is (= page-uuid (:block/uuid (:block/parent child))))))))
(deftest undo-skips-conflicted-move-and-keeps-earlier-history-test
(testing "undo skips a conflicting move and continues to earlier safe history"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [parent-a-uuid parent-b-uuid child-uuid]} (seed-page-two-parents-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "local-title"]]
{:outliner-op :save-block
:local-tx? true})
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/parent [:block/uuid parent-b-uuid]]]
{:outliner-op :move-blocks
:local-tx? true})
(d/transact! conn
(:tx-data (outliner-core/delete-blocks @conn [(d/entity @conn [:block/uuid parent-a-uuid])] {}))
{:outliner-op :delete-blocks
:local-tx? false})
(let [undo-result (undo-redo/undo test-db)
child (d/entity @conn [:block/uuid child-uuid])]
(is (map? undo-result))
(is (= "child" (:block/title child)))
(is (= parent-b-uuid
(:block/uuid (:block/parent child))))
(is (empty? (db-issues @conn)))))))
(deftest undo-validation-fast-path-skips-db-issues-for-non-structural-tx-test
(testing "undo validation skips db-issues for non-structural tx-data"
(let [conn (db/get-db test-db false)
{:keys [child-uuid]} (seed-page-parent-child!)]
(with-redefs [undo-validate/issues-for-entity-ids (fn [_ _]
(throw (js/Error. "issues-for-entity-ids called")))]
(is (true? (undo-validate/valid-undo-redo-tx?
conn
[[:db/add [:block/uuid child-uuid] :block/title "child-updated"]])))))))
(deftest undo-validation-checks-structural-tx-test
(testing "undo validation evaluates structural changes"
(let [conn (db/get-db test-db false)
{:keys [page-uuid child-uuid]} (seed-page-parent-child!)
calls (atom 0)]
(with-redefs [undo-validate/issues-for-entity-ids (fn [_ _]
(swap! calls inc)
[])]
(is (true? (undo-validate/valid-undo-redo-tx?
conn
[[:db/add [:block/uuid child-uuid] :block/parent [:block/uuid page-uuid]]])))
(is (pos? @calls))))))
(deftest redo-builds-reversed-tx-when-target-parent-is-recycled-test
(testing "redo still builds reversed tx from raw datoms when target parent was recycled remotely"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid parent-a-uuid parent-b-uuid]} (seed-page-two-parents-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/parent [:block/uuid parent-b-uuid]]]
{:outliner-op :move-blocks
:local-tx? true})
(undo-redo/undo test-db)
(d/transact! conn
(:tx-data (outliner-core/delete-blocks @conn [(d/entity @conn [:block/uuid parent-b-uuid])] {}))
{:outliner-op :delete-blocks
:local-tx? false})
(let [redo-op (last (get @undo-redo/*redo-ops test-db))
data (some #(when (= ::undo-redo/db-transact (first %))
(second %))
redo-op)
reversed (undo-redo/get-reversed-datoms conn false data (:tx-meta data))]
(is (seq reversed))
(is (= parent-a-uuid
(:block/uuid (:block/parent (d/entity @conn [:block/uuid child-uuid])))))))))
(deftest undo-skips-move-when-original-parent-is-recycled-test
(testing "undo should skip a move whose original parent has been recycled"
(undo-redo/clear-history! test-db)
(let [conn (db/get-db test-db false)
{:keys [child-uuid parent-a-uuid parent-b-uuid]} (seed-page-two-parents-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/parent [:block/uuid parent-b-uuid]]]
{:outliner-op :move-blocks
:local-tx? true})
(d/transact! conn
(:tx-data (outliner-core/delete-blocks @conn [(d/entity @conn [:block/uuid parent-a-uuid])] {}))
{:outliner-op :delete-blocks
:local-tx? false})
(let [parent-a (d/entity @conn [:block/uuid parent-a-uuid])
_ (is (some? parent-a))
_ (is (true? (ldb/recycled? parent-a)))
undo-op (last (get @undo-redo/*undo-ops test-db))
data (some #(when (= ::undo-redo/db-transact (first %))
(second %))
undo-op)
conflicted? (#'undo-redo/reversed-structural-target-conflicted?
conn
(->> (:tx-data data) reverse (group-by :e))
true)
reversed (undo-redo/get-reversed-datoms conn true data (:tx-meta data))]
(is (true? conflicted?))
(is (nil? reversed))))))
(deftest ^:long undo-redo-test
(testing "Random mixed operations"
(set! undo-redo/max-stack-length 500)
(let [*random-blocks (atom (outliner-test/get-blocks-ids))]
(outliner-test/transact-random-tree!)
(let [conn (db/get-db test-db false)]
(d/transact! conn
[{:db/ident :logseq.class/Page}
[:db/add [:block/uuid 1] :block/tags :logseq.class/Page]]
{:local-tx? false}))
(let [conn (db/get-db false)
_ (outliner-test/run-random-mixed-ops! *random-blocks)]
(undo-all!)
(is (empty? (db-issues @conn)))
(redo-all!)
(is (empty? (db-issues @conn)))))))
(deftest node-test-undo-redo-does-not-call-worker-test
(let [calls (atom [])
invoke! (fn [& args]
(swap! calls conj (vec args))
(vec args))
repo "repo-node"]
(with-redefs [util/node-test? true
state/<invoke-db-worker invoke!]
(is (= :frontend.undo-redo/empty-undo-stack
(undo-redo/undo repo)))
(is (= :frontend.undo-redo/empty-redo-stack
(undo-redo/redo repo)))
(is (nil? (undo-redo/clear-history! repo)))
(is (empty? @calls)))))

View File

@@ -73,9 +73,9 @@
(let [next-time (get-next-time one-month-ago month-unit 1)]
(is (> (in-days next-time) 1)))
(let [next-time (get-next-time one-month-ago month-unit 3)]
(is (= 2 (in-months next-time))))
(is (contains? #{1 2} (in-months next-time))))
(let [next-time (get-next-time one-month-ago month-unit 5)]
(is (= 4 (in-months next-time))))
(is (contains? #{3 4} (in-months next-time))))
;; year
(let [next-time (get-next-time now year-unit 1)]

View File

@@ -0,0 +1,16 @@
(ns frontend.worker.db-listener-test
(:require [cljs.test :refer [deftest is testing]]
[frontend.worker.db-listener :as db-listener]))
(deftest transit-safe-tx-meta-keeps-outliner-ops-test
(testing "worker tx-meta sanitization should preserve semantic outliner ops"
(let [outliner-ops [[:save-block [{:block/uuid (random-uuid)
:block/title "hello"} nil]]]
tx-meta {:outliner-op :save-block
:outliner-ops outliner-ops
:db-sync/inverse-outliner-ops outliner-ops
:error-handler (fn [_] nil)}
safe-tx-meta (#'db-listener/transit-safe-tx-meta tx-meta)]
(is (= outliner-ops (:outliner-ops safe-tx-meta)))
(is (= outliner-ops (:db-sync/inverse-outliner-ops safe-tx-meta)))
(is (nil? (:error-handler safe-tx-meta))))))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,20 @@
(ns frontend.worker.db-worker-test
(:require [cljs.test :refer [async deftest is]]
[datascript.core :as d]
[frontend.common.thread-api :as thread-api]
[frontend.worker.a-test-env]
[frontend.worker.db-worker :as db-worker]
[frontend.worker.search :as search]
[frontend.worker.shared-service :as shared-service]
[frontend.worker.state :as worker-state]
[frontend.worker.sync :as db-sync]
[frontend.worker.sync.client-op :as client-op]
[frontend.worker.sync.crypt :as sync-crypt]
[frontend.worker.sync.log-and-state :as rtc-log-and-state]
[logseq.db.frontend.schema :as db-schema]
[promesa.core :as p]))
(:require
[cljs.test :refer [async deftest is]]
[datascript.core :as d]
[frontend.common.thread-api :as thread-api]
[frontend.worker.a-test-env]
[frontend.worker.db-worker :as db-worker]
[frontend.worker.db.validate :as worker-db-validate]
[frontend.worker.search :as search]
[frontend.worker.shared-service :as shared-service]
[frontend.worker.state :as worker-state]
[frontend.worker.sync :as db-sync]
[frontend.worker.sync.client-op :as client-op]
[frontend.worker.sync.crypt :as sync-crypt]
[frontend.worker.sync.log-and-state :as rtc-log-and-state]
[logseq.db.frontend.schema :as db-schema]
[promesa.core :as p]))
(def ^:private test-repo "test-db-worker-repo")
(def ^:private close-db!-orig db-worker/close-db!)
@@ -73,14 +75,56 @@
(reset! worker-state/*opfs-pools
{test-repo #js {:pauseVfs (fn [] (swap! pause-calls inc))}})
(reset! search/fuzzy-search-indices {test-repo :stale-cache})
(reset! client-op/*repo->pending-local-tx-count {test-repo 9})
(db-worker/close-db! test-repo)
(is (= #{:db :search :client-ops} (set @closed)))
(is (= 1 @pause-calls))
(is (nil? (get @search/fuzzy-search-indices test-repo)))
(is (nil? (get @client-op/*repo->pending-local-tx-count test-repo)))
(is (nil? (get @worker-state/*sqlite-conns test-repo)))))))
(deftest client-ops-cleanup-timer-starts-once-and-clears-on-close-test
(restoring-worker-state
(fn []
(let [scheduled (atom [])
cleared (atom [])
original-set-interval js/setInterval
original-clear-interval js/clearInterval
fake-db #js {:close (fn [] nil)}
timer-id #js {:id "timer-1"}]
(set! js/setInterval
(fn [f interval-ms]
(swap! scheduled conj {:fn f :interval-ms interval-ms})
timer-id))
(set! js/clearInterval
(fn [id]
(swap! cleared conj id)))
(try
(reset! worker-state/*sqlite-conns
{test-repo {:db fake-db
:search fake-db
:client-ops fake-db}})
(reset! worker-state/*datascript-conns {test-repo :datascript})
(reset! worker-state/*client-ops-conns {test-repo :client-ops})
(reset! (deref #'db-worker/*client-ops-cleanup-timers) {})
(#'db-worker/ensure-client-ops-cleanup-timer! test-repo)
(#'db-worker/ensure-client-ops-cleanup-timer! test-repo)
(is (= 1 (count @scheduled)))
(is (= (* 3 60 60 1000) (:interval-ms (first @scheduled))))
(is (= timer-id (get @(deref #'db-worker/*client-ops-cleanup-timers) test-repo)))
(db-worker/close-db! test-repo)
(is (= [timer-id] @cleared))
(is (nil? (get @(deref #'db-worker/*client-ops-cleanup-timers) test-repo)))
(finally
(set! js/setInterval original-set-interval)
(set! js/clearInterval original-clear-interval)))))))
(deftest complete-datoms-import-invalidates-existing-search-db-test
(async done
(restoring-worker-state
@@ -288,3 +332,61 @@
(p/catch (fn [error]
(is false (str error))
(done)))))))))))
(deftest thread-api-validate-db-passes-sync-diagnostics-test
(restoring-worker-state
(fn []
(let [validate (@thread-api/*thread-apis :thread-api/validate-db)
conn (d/create-conn db-schema/schema)
captured (atom nil)
latest-prev @db-sync/*repo->latest-remote-tx]
(reset! worker-state/*datascript-conns {test-repo conn})
(reset! db-sync/*repo->latest-remote-tx {test-repo 11})
(try
(with-redefs [client-op/get-local-tx (fn [_repo] 7)
client-op/get-local-checksum (fn [_repo] "local-checksum")
worker-db-validate/validate-db (fn [& args]
(reset! captured args)
{:ok true})]
(validate test-repo)
(is (= [test-repo
conn
{:local-tx 7
:remote-tx 11
:local-checksum "local-checksum"
:remote-checksum nil}]
@captured)))
(finally
(reset! db-sync/*repo->latest-remote-tx latest-prev)))))))
(deftest thread-api-recompute-checksum-diagnostics-passes-sync-diagnostics-test
(restoring-worker-state
(fn []
(let [recompute (@thread-api/*thread-apis :thread-api/recompute-checksum-diagnostics)
conn (d/create-conn db-schema/schema)
captured (atom nil)
latest-tx-prev @db-sync/*repo->latest-remote-tx
latest-checksum-prev @db-sync/*repo->latest-remote-checksum
result {:recomputed-checksum "recomputed"
:checksum-attrs [:block/uuid]
:blocks []}]
(reset! worker-state/*datascript-conns {test-repo conn})
(reset! db-sync/*repo->latest-remote-tx {test-repo 22})
(reset! db-sync/*repo->latest-remote-checksum {test-repo "remote-checksum"})
(try
(with-redefs [client-op/get-local-tx (fn [_repo] 10)
client-op/get-local-checksum (fn [_repo] "local-checksum")
worker-db-validate/recompute-checksum-diagnostics (fn [& args]
(reset! captured args)
result)]
(is (= result (recompute test-repo)))
(is (= [test-repo
conn
{:local-tx 10
:remote-tx 22
:local-checksum "local-checksum"
:remote-checksum "remote-checksum"}]
@captured)))
(finally
(reset! db-sync/*repo->latest-remote-tx latest-tx-prev)
(reset! db-sync/*repo->latest-remote-checksum latest-checksum-prev)))))))

View File

@@ -1,5 +1,5 @@
(ns frontend.worker.sync.client-op-test
(:require [cljs.test :refer [deftest is]]
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[frontend.worker.state :as worker-state]
[frontend.worker.sync.client-op :as client-op]))
@@ -17,3 +17,40 @@
(is (= #{"graph-2"} (set (map :v graph-uuid-datoms)))))
(finally
(reset! worker-state/*client-ops-conns prev-client-ops-conns)))))
(deftest cleanup-finished-history-ops-removes-only-unreferenced-finished-txs-test
(let [repo "repo-cleanup"
conn (d/create-conn client-op/schema-in-db)
prev-client-ops-conns @worker-state/*client-ops-conns
keep-tx-id (random-uuid)
remove-tx-id (random-uuid)
pending-tx-id (random-uuid)]
(reset! worker-state/*client-ops-conns {repo conn})
(try
(d/transact! conn
[{:db-sync/tx-id keep-tx-id
:db-sync/pending? false}
{:db-sync/tx-id remove-tx-id
:db-sync/pending? false}
{:db-sync/tx-id pending-tx-id
:db-sync/pending? true}
{:db-ident :metadata/local
:local-tx 99}])
(is (= 1 (client-op/cleanup-finished-history-ops! repo #{keep-tx-id})))
(is (some? (d/entity @conn [:db-sync/tx-id keep-tx-id])))
(is (nil? (d/entity @conn [:db-sync/tx-id remove-tx-id])))
(is (some? (d/entity @conn [:db-sync/tx-id pending-tx-id])))
(is (= 99 (:local-tx (d/entity @conn [:db-ident :metadata/local]))))
(finally
(reset! worker-state/*client-ops-conns prev-client-ops-conns)))))
(deftest cleanup-finished-history-ops-no-conn-is-noop-test
(let [repo "repo-no-conn"
prev-client-ops-conns @worker-state/*client-ops-conns]
(reset! worker-state/*client-ops-conns {})
(try
(testing "cleanup should be safe when client-ops conn is missing"
(is (= 0 (client-op/cleanup-finished-history-ops! repo #{}))))
(finally
(reset! worker-state/*client-ops-conns prev-client-ops-conns)))))

View File

@@ -46,3 +46,72 @@
(p/catch (fn [e]
(is false (str e))
(done))))))
(deftest fetch-graph-aes-key-for-download-retries-with-fresh-rsa-key-pair-test
(async done
(let [clear-user-rsa-cache-calls* (atom 0)
get-pair-calls* (atom 0)]
(-> (p/with-redefs [sync-crypt/e2ee-base (fn [] "https://sync.example.test")
sync-crypt/get-user-uuid (fn [] "user-1")
sync-crypt/<clear-item! (fn [_] (p/resolved nil))
sync-crypt/<set-item! (fn [_ _] (p/resolved nil))
sync-crypt/<clear-user-rsa-key-pair-cache! (fn [_base _user-id]
(swap! clear-user-rsa-cache-calls* inc)
(p/resolved nil))
sync-crypt/<get-user-rsa-key-pair-raw (fn [_base]
(swap! get-pair-calls* inc)
(if (= 1 @get-pair-calls*)
(p/resolved {:public-key "pk-old"
:encrypted-private-key "enc-old"})
(p/resolved {:public-key "pk-new"
:encrypted-private-key "enc-new"})))
sync-crypt/<decrypt-private-key (fn [encrypted-private-key]
(p/resolved
(case encrypted-private-key
"enc-old" :private-key-old
"enc-new" :private-key-new
:private-key-unknown)))
sync-crypt/<fetch-graph-encrypted-aes-key-raw (fn [_base _graph-id]
(p/resolved {:encrypted-aes-key
(ldb/write-transit-str "encrypted-aes")}))
crypt/<decrypt-aes-key (fn [private-key encrypted-aes-key]
(if (= :private-key-old private-key)
(p/rejected (ex-info "decrypt-aes-key" {}))
(p/resolved [:aes-key private-key encrypted-aes-key])))]
(sync-crypt/<fetch-graph-aes-key-for-download "graph-1"))
(p/then (fn [result]
(is (= [:aes-key :private-key-new "encrypted-aes"] result))
(is (= 1 @clear-user-rsa-cache-calls*))
(is (= 2 @get-pair-calls*))
(done)))
(p/catch (fn [e]
(is false (str e))
(done)))))))
(deftest fetch-graph-aes-key-for-download-rethrows-without-user-id-test
(async done
(let [clear-user-rsa-cache-calls* (atom 0)]
(-> (p/with-redefs [sync-crypt/e2ee-base (fn [] "https://sync.example.test")
sync-crypt/get-user-uuid (fn [] nil)
sync-crypt/<clear-item! (fn [_] (p/resolved nil))
sync-crypt/<set-item! (fn [_ _] (p/resolved nil))
sync-crypt/<clear-user-rsa-key-pair-cache! (fn [_base _user-id]
(swap! clear-user-rsa-cache-calls* inc)
(p/resolved nil))
sync-crypt/<get-user-rsa-key-pair-raw (fn [_base]
(p/resolved {:public-key "pk-old"
:encrypted-private-key "enc-old"}))
sync-crypt/<decrypt-private-key (fn [_] (p/resolved :private-key-old))
sync-crypt/<fetch-graph-encrypted-aes-key-raw (fn [_base _graph-id]
(p/resolved {:encrypted-aes-key
(ldb/write-transit-str "encrypted-aes")}))
crypt/<decrypt-aes-key (fn [_ _]
(p/rejected (ex-info "decrypt-aes-key" {})))]
(sync-crypt/<fetch-graph-aes-key-for-download "graph-1"))
(p/then (fn [_]
(is false "expected decrypt-aes-key failure")
(done)))
(p/catch (fn [e]
(is (= "decrypt-aes-key" (ex-message e)))
(is (zero? @clear-user-rsa-cache-calls*))
(done)))))))

View File

@@ -0,0 +1,936 @@
(ns frontend.worker.undo-redo-test
(:require [cljs.test :refer [deftest is testing use-fixtures]]
[datascript.core :as d]
[frontend.worker.a-test-env]
[frontend.worker.sync.apply-txs :as sync-apply]
[frontend.worker.state :as worker-state]
[frontend.worker.sync :as db-sync]
[frontend.worker.sync.client-op :as client-op]
[frontend.worker.undo-redo :as worker-undo-redo]
[logseq.common.util.date-time :as date-time-util]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.op :as outliner-op]))
(def ^:private test-repo "test-worker-undo-redo")
(defn- local-tx-meta
[m]
(assoc m
:local-tx? true
:db-sync/tx-id (or (:db-sync/tx-id m) (random-uuid))))
(defn- with-worker-conns
[f]
(let [datascript-prev @worker-state/*datascript-conns
client-ops-prev @worker-state/*client-ops-conns
apply-history-action-prev @worker-undo-redo/*apply-history-action!
conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page 1"}
:blocks [{:block/title "task"}
{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
client-ops-conn (d/create-conn client-op/schema-in-db)]
(reset! worker-state/*datascript-conns {test-repo conn})
(reset! worker-state/*client-ops-conns {test-repo client-ops-conn})
(reset! worker-undo-redo/*apply-history-action! sync-apply/apply-history-action!)
(d/listen! conn ::gen-undo-ops
(fn [tx-report]
(db-sync/enqueue-local-tx! test-repo tx-report)))
(worker-undo-redo/clear-history! test-repo)
(try
(f)
(finally
(d/unlisten! conn ::gen-undo-ops)
(worker-undo-redo/clear-history! test-repo)
(reset! worker-undo-redo/*apply-history-action! apply-history-action-prev)
(reset! worker-state/*datascript-conns datascript-prev)
(reset! worker-state/*client-ops-conns client-ops-prev)))))
(use-fixtures :each with-worker-conns)
(deftest worker-ui-state-roundtrip-test
(let [ui-state-str "{:old-state {}, :new-state {:route-data {:to :page}}}"]
(worker-undo-redo/record-ui-state! test-repo ui-state-str)
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (= ui-state-str (:ui-state-str undo-result)))
(is (true? (:undo? undo-result))))
(let [redo-result (worker-undo-redo/redo test-repo)]
(is (= ui-state-str (:ui-state-str redo-result)))
(is (false? (:undo? redo-result))))))
(defn- seed-page-parent-child!
[]
(let [conn (worker-state/get-datascript-conn test-repo)
page-uuid (:block/uuid (db-test/find-page-by-title @conn "page 1"))
parent-uuid (:block/uuid (db-test/find-block-by-content @conn "parent"))
child-uuid (d/q '[:find ?child-uuid .
:in $ ?parent-uuid
:where
[?parent :block/uuid ?parent-uuid]
[?child :block/parent ?parent]
[?child :block/uuid ?child-uuid]]
@conn
parent-uuid)]
{:page-uuid page-uuid
:parent-uuid parent-uuid
:child-uuid child-uuid}))
(defn- save-block-title!
([conn block-uuid title]
(save-block-title! conn block-uuid title (random-uuid)))
([conn block-uuid title tx-id]
(d/transact! conn
[[:db/add [:block/uuid block-uuid] :block/title title]]
(local-tx-meta
{:db-sync/tx-id tx-id
:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid block-uuid
:block/title title} {}]]]}))))
(deftest undo-redo-selection-editor-info-roundtrip-test
(testing "undo/redo result keeps block selection editor info when no cursor is recorded"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)
selection-info {:selected-block-uuids [child-uuid]
:selection-direction :down}]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "selection-history"]]
(local-tx-meta
{:outliner-op :save-block
:undo-redo/editor-info selection-info
:outliner-ops [[:save-block [{:block/uuid child-uuid
:block/title "selection-history"} {}]]]}))
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (= [selection-info] (:editor-cursors undo-result)))
(is (nil? (:block-content undo-result))))
(let [redo-result (worker-undo-redo/redo test-repo)]
(is (= [selection-info] (:editor-cursors redo-result)))
(is (nil? (:block-content redo-result)))))))
(defn- undo-all!
[]
(loop [results []]
(let [result (worker-undo-redo/undo test-repo)]
(if (= ::worker-undo-redo/empty-undo-stack result)
results
(recur (conj results result))))))
(defn- redo-all!
[]
(loop [results []]
(let [result (worker-undo-redo/redo test-repo)]
(if (= ::worker-undo-redo/empty-redo-stack result)
results
(recur (conj results result))))))
(defn- latest-undo-history-data
[]
(let [undo-op (last (get @worker-undo-redo/*undo-ops test-repo))]
(some #(when (= ::worker-undo-redo/db-transact (first %))
(second %))
undo-op)))
(defn- latest-redo-history-data
[]
(let [redo-op (last (get @worker-undo-redo/*redo-ops test-repo))]
(some #(when (= ::worker-undo-redo/db-transact (first %))
(second %))
redo-op)))
(deftest undo-missing-history-action-row-replays-from-inline-ops-test
(testing "undo/redo should replay from inline history ops when pending row is missing"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
client-ops-conn (get @worker-state/*client-ops-conns test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)
tx-id-1 (random-uuid)
tx-id-2 (random-uuid)]
(save-block-title! conn child-uuid "v1" tx-id-1)
(save-block-title! conn child-uuid "v2" tx-id-2)
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(is (= 2 (count (get @worker-undo-redo/*undo-ops test-repo))))
(is (seq (:db-sync/forward-outliner-ops (latest-undo-history-data))))
(is (seq (:db-sync/inverse-outliner-ops (latest-undo-history-data))))
;; Poison tx-data so undo/redo must not rely on raw datoms.
(swap! worker-undo-redo/*undo-ops
update test-repo
(fn [stack]
(update stack
(dec (count stack))
(fn [op]
(mapv (fn [item]
(if (= ::worker-undo-redo/db-transact (first item))
[::worker-undo-redo/db-transact
(assoc (second item)
:tx-data [(d/datom 1 :block/title "poisoned" 1 true)])]
item))
op)))))
(when-let [tx-ent (d/entity @client-ops-conn [:db-sync/tx-id tx-id-2])]
(ldb/transact! client-ops-conn [[:db/retractEntity (:db/id tx-ent)]]))
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (not= ::worker-undo-redo/empty-undo-stack undo-result))
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(is (= 1 (count (get @worker-undo-redo/*undo-ops test-repo))))
(is (= 1 (count (get @worker-undo-redo/*redo-ops test-repo)))))
(let [redo-result (worker-undo-redo/redo test-repo)]
(is (not= ::worker-undo-redo/empty-redo-stack redo-result))
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(deftest redo-invalid-history-action-result-keeps-redo-strict-test
(testing "redo should not silently skip invalid worker results"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)
tx-id-1 (random-uuid)
tx-id-2 (random-uuid)
prev-apply-action @worker-undo-redo/*apply-history-action!]
(try
(save-block-title! conn child-uuid "v1" tx-id-1)
(save-block-title! conn child-uuid "v2" tx-id-2)
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(reset! worker-undo-redo/*apply-history-action!
(fn [_repo _tx-id _undo? _tx-meta]
{:applied? false
:reason :invalid-history-action-tx}))
(is (= ::worker-undo-redo/empty-redo-stack
(worker-undo-redo/redo test-repo)))
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(is (empty? (get @worker-undo-redo/*undo-ops test-repo)))
(is (empty? (get @worker-undo-redo/*redo-ops test-repo)))
(finally
(reset! worker-undo-redo/*apply-history-action! prev-apply-action))))))
(deftest undo-skippable-worker-error-does-not-fallback-to-local-tx-test
(testing "undo should not fallback to tx-data when worker reports skippable invalid ops"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)
tx-id-1 (random-uuid)
tx-id-2 (random-uuid)
prev-apply-action @worker-undo-redo/*apply-history-action!]
(try
(save-block-title! conn child-uuid "v1" tx-id-1)
(save-block-title! conn child-uuid "v2" tx-id-2)
(reset! worker-undo-redo/*apply-history-action!
(fn [_repo _tx-id _undo? _tx-meta]
(throw (ex-info "semantic-error-renamed"
{:reason :invalid-history-action-ops}))))
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (= ::worker-undo-redo/empty-undo-stack undo-result))
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid])))))
(finally
(reset! worker-undo-redo/*apply-history-action! prev-apply-action))))))
(deftest undo-row-missing-and-poisoned-tx-data-does-not-clear-history-test
(testing "missing pending row with poisoned tx-data should not clear undo history"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
client-ops-conn (get @worker-state/*client-ops-conns test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)
tx-id (random-uuid)]
(save-block-title! conn child-uuid "new-title" tx-id)
(swap! worker-undo-redo/*undo-ops
update test-repo
(fn [stack]
(update stack
(dec (count stack))
(fn [op]
(mapv (fn [item]
(if (= ::worker-undo-redo/db-transact (first item))
[::worker-undo-redo/db-transact
(assoc (second item) :tx-data [(d/datom 1 :block/title "poisoned" 1 true)])]
item))
op)))))
(when-let [tx-ent (d/entity @client-ops-conn [:db-sync/tx-id tx-id])]
(ldb/transact! client-ops-conn [[:db/retractEntity (:db/id tx-ent)]]))
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(is (seq (get @worker-undo-redo/*redo-ops test-repo))))))
(deftest undo-redo-rebinds-stack-to-latest-history-tx-id-test
(testing "undo/redo pushes stack op with latest persisted history tx id"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
client-ops-conn (get @worker-state/*client-ops-conns test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(save-block-title! conn child-uuid "v1")
(let [source-tx-id (:db-sync/tx-id (latest-undo-history-data))]
(is (uuid? source-tx-id))
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(let [redo-tx-id (:db-sync/tx-id (latest-redo-history-data))]
(is (uuid? redo-tx-id))
(is (= source-tx-id redo-tx-id))
(is (not= ::worker-undo-redo/empty-redo-stack
(worker-undo-redo/redo test-repo)))
(let [undo-tx-id (:db-sync/tx-id (latest-undo-history-data))]
(is (uuid? undo-tx-id))
(is (not= source-tx-id undo-tx-id))
(is (some? (d/entity @client-ops-conn [:db-sync/tx-id undo-tx-id])))))))))
(deftest undo-records-only-local-txs-test
(testing "undo history records only local txs"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(save-block-title! conn child-uuid "local-update")
(is (= 1 (count (get @worker-undo-redo/*undo-ops test-repo)))))
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "remote-update"]]
{:outliner-op :save-block
:local-tx? false})
(is (empty? (get @worker-undo-redo/*undo-ops test-repo))))))
(deftest undo-history-records-semantic-action-metadata-test
(testing "worker undo history stores a logical action id and semantic forward/inverse ops"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "semantic-save"]]
(local-tx-meta
{:client-id "test-client"
:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid child-uuid
:block/title "semantic-save"} {}]]]}))
(let [undo-op (last (get @worker-undo-redo/*undo-ops test-repo))
data (some #(when (= ::worker-undo-redo/db-transact (first %))
(second %))
undo-op)]
(is (uuid? (:db-sync/tx-id data)))
(is (= :save-block (ffirst (:db-sync/forward-outliner-ops data))))
(is (= :save-block (ffirst (:db-sync/inverse-outliner-ops data))))
(is (= child-uuid
(get-in data [:db-sync/forward-outliner-ops 0 1 0 :block/uuid])))
(is (= child-uuid
(get-in data [:db-sync/inverse-outliner-ops 0 1 0 :block/uuid])))))))
(deftest undo-history-allows-non-semantic-outliner-op-test
(testing "non-semantic outliner-op with transact placeholder is skipped by ops-only undo history"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(d/transact! conn
[[:db/add [:block/uuid child-uuid] :block/title "restored child"]]
(local-tx-meta
{:client-id "test-client"
:outliner-op :restore-recycled
:outliner-ops [[:transact nil]]}))
(is (empty? (get @worker-undo-redo/*undo-ops test-repo))))))
(deftest undo-history-canonicalizes-insert-block-uuids-test
(testing "worker undo history uses the created block uuid for insert semantic ops"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
requested-uuid (random-uuid)]
(d/transact! conn
[{:block/uuid requested-uuid
:block/title "semantic insert"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}]
(local-tx-meta
{:client-id "test-client"
:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/title "semantic insert"
:block/uuid requested-uuid}]
page-id
{:sibling? false}]]]}))
(let [inserted-id (d/q '[:find ?e .
:in $ ?title
:where
[?e :block/title ?title]]
@conn
"semantic insert")
inserted (d/entity @conn inserted-id)
inserted-uuid (:block/uuid inserted)
undo-op (last (get @worker-undo-redo/*undo-ops test-repo))
data (some #(when (= ::worker-undo-redo/db-transact (first %))
(second %))
undo-op)]
(is (= inserted-uuid
(get-in data [:db-sync/forward-outliner-ops 0 1 0 0 :block/uuid])))
(is (= inserted-uuid
(second (first (get-in data [:db-sync/inverse-outliner-ops 0 1 0])))))))))
(deftest undo-works-for-local-graph-test
(testing "worker undo/redo works for local changes on local graph"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(save-block-title! conn child-uuid "local-1")
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (map? undo-result))
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid])))))
(let [redo-result (worker-undo-redo/redo test-repo)]
(is (map? redo-result))
(is (= "local-1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(deftest undo-delete-page-restores-page-out-of-recycle-test
(testing "undoing delete-page should restore page and clear recycle marker"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)]
(outliner-op/apply-ops! conn
[[:delete-page [page-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(let [deleted-page (d/entity @conn [:block/uuid page-uuid])]
(is (some? deleted-page))
(is (true? (ldb/recycled? deleted-page))))
(let [undo-result (worker-undo-redo/undo test-repo)
restored-page (d/entity @conn [:block/uuid page-uuid])]
(is (map? undo-result))
(is (some? restored-page))
(is (false? (ldb/recycled? restored-page)))
(is (nil? (:block/parent restored-page)))
(is (nil? (:logseq.property/deleted-at restored-page)))
(is (nil? (:logseq.property.recycle/original-parent restored-page)))
(is (nil? (:logseq.property.recycle/original-page restored-page)))
(is (nil? (:logseq.property.recycle/original-order restored-page)))))))
(deftest undo-delete-page-restores-class-property-and-today-page-test
(testing "undoing delete-page restores hard-retracted class/property pages and today page blocks"
(let [conn (worker-state/get-datascript-conn test-repo)
class-title "undo class page movie"
[_ class-uuid] (outliner-op/apply-ops! conn
[[:create-page [class-title
{:class? true
:redirect? false
:split-namespace? true
:tags ()}]]]
(local-tx-meta {:client-id "test-client"}))
_ (outliner-op/apply-ops! conn
[[:upsert-property [:user.property/undo-rating
{:logseq.property/type :number}
{:property-name "undo-rating"}]]]
(local-tx-meta {:client-id "test-client"}))
property-page (d/entity @conn :user.property/undo-rating)
property-uuid (:block/uuid property-page)
today-day (date-time-util/ms->journal-day (js/Date.))
today-title (date-time-util/int->journal-title
today-day
(:logseq.property.journal/title-format
(d/entity @conn :logseq.class/Journal)))
[_ today-page-uuid] (outliner-op/apply-ops! conn
[[:create-page [today-title
{:today-journal? true
:redirect? false
:split-namespace? true
:tags ()}]]]
(local-tx-meta {:client-id "test-client"}))
today-page-id (:db/id (d/entity @conn [:block/uuid today-page-uuid]))
today-child-uuid (random-uuid)
_ (outliner-op/apply-ops! conn
[[:insert-blocks [[{:block/uuid today-child-uuid
:block/title "today undo child"}]
today-page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
class-ident-before (:db/ident (d/entity @conn [:block/uuid class-uuid]))
property-ident-before (:db/ident (d/entity @conn [:block/uuid property-uuid]))]
(worker-undo-redo/clear-history! test-repo)
(outliner-op/apply-ops! conn
[[:delete-page [class-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(is (nil? (d/entity @conn [:block/uuid class-uuid])))
(is (map? (worker-undo-redo/undo test-repo)))
(is (= class-ident-before
(:db/ident (d/entity @conn [:block/uuid class-uuid]))))
(worker-undo-redo/clear-history! test-repo)
(outliner-op/apply-ops! conn
[[:delete-page [property-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(is (nil? (d/entity @conn :user.property/undo-rating)))
(is (map? (worker-undo-redo/undo test-repo)))
(is (= property-ident-before
(:db/ident (d/entity @conn [:block/uuid property-uuid]))))
(worker-undo-redo/clear-history! test-repo)
(outliner-op/apply-ops! conn
[[:delete-page [today-page-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(is (some? (d/entity @conn [:block/uuid today-page-uuid])))
(is (nil? (d/entity @conn [:block/uuid today-child-uuid])))
(is (map? (worker-undo-redo/undo test-repo)))
(is (some? (d/entity @conn [:block/uuid today-child-uuid]))))))
(deftest redo-create-page-restores-recycled-page-test
(testing "redoing create-page should restore recycled page instead of keeping it recycled"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
page-title "redo create page alpha"]
(outliner-op/apply-ops! conn
[[:create-page [page-title {:redirect? false
:split-namespace? true
:tags ()}]]]
(local-tx-meta {:client-id "test-client"}))
(let [created-page (db-test/find-page-by-title @conn page-title)]
(is (some? created-page))
(is (false? (ldb/recycled? created-page))))
(is (seq (undo-all!)))
(let [deleted-page (db-test/find-page-by-title @conn page-title)]
(is (some? deleted-page))
(is (true? (ldb/recycled? deleted-page))))
(is (seq (redo-all!)))
(let [page-after-redo (db-test/find-page-by-title @conn page-title)]
(is (some? page-after-redo))
(is (false? (ldb/recycled? page-after-redo)))))))
(deftest redo-template-insert-restores-valid-blocks-test
(testing "redoing template insert after undo-all should restore inserted template blocks without invalid refs"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
template-root-uuid (random-uuid)
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
:block/tags #{:logseq.class/Template}}
{:block/uuid template-a-uuid
:block/title "a"
:block/parent [:block/uuid template-root-uuid]}
{:block/uuid template-b-uuid
:block/title "b"
:block/parent [:block/uuid template-a-uuid]}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(let [template-root (d/entity @conn [:block/uuid template-root-uuid])
empty-target (d/entity @conn [:block/uuid empty-target-uuid])
template-blocks (->> (ldb/get-block-and-children @conn template-root-uuid
{:include-property-block? true})
rest)
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))]
(outliner-op/apply-ops!
conn
[[:insert-blocks [blocks-to-insert
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:insert-template? true}]]]
(local-tx-meta {:client-id "test-client"})))
(is (seq (undo-all!)))
(is (seq (redo-all!)))
(let [inserted-a-id (d/q '[:find ?b .
:in $ ?template-uuid
:where
[?template :block/uuid ?template-uuid]
[?b :logseq.property/used-template ?template]
[?b :block/title "a"]]
@conn
template-root-uuid)
inserted-a (when inserted-a-id (d/entity @conn inserted-a-id))]
(is (some? inserted-a))
(is (= template-root-uuid
(some-> inserted-a :logseq.property/used-template :block/uuid)))))))
(deftest undo-history-canonicalizes-template-replace-empty-target-to-apply-template-test
(testing "template replace-empty-target history keeps semantic forward op and restores empty target"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
template-root-uuid (random-uuid)
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
:block/tags #{:logseq.class/Template}}
{:block/uuid template-a-uuid
:block/title "a"
:block/parent [:block/uuid template-root-uuid]}
{:block/uuid template-b-uuid
:block/title "b"
:block/parent [:block/uuid template-a-uuid]}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(let [template-root (d/entity @conn [:block/uuid template-root-uuid])
empty-target (d/entity @conn [:block/uuid empty-target-uuid])
template-blocks (->> (ldb/get-block-and-children @conn template-root-uuid
{:include-property-block? true})
rest)
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))]
(outliner-op/apply-ops!
conn
[[:insert-blocks [blocks-to-insert
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:insert-template? true}]]]
(local-tx-meta {:client-id "test-client"})))
(let [data (latest-undo-history-data)
inverse-ops (:db-sync/inverse-outliner-ops data)
delete-op (some #(when (= :delete-blocks (first %)) %) inverse-ops)
restore-empty-insert-op (some #(when (= :insert-blocks (first %)) %) inverse-ops)
restore-empty-save-op (some #(when (= :save-block (first %)) %) inverse-ops)]
(is (contains? #{:apply-template :insert-blocks}
(ffirst (:db-sync/forward-outliner-ops data))))
(is (some? delete-op))
(is (or (some? restore-empty-insert-op)
(some? restore-empty-save-op)))
(if restore-empty-insert-op
(do
(is (= empty-target-uuid
(get-in restore-empty-insert-op [1 0 0 :block/uuid])))
(is (= ""
(get-in restore-empty-insert-op [1 0 0 :block/title]))))
(do
(is (= empty-target-uuid
(get-in restore-empty-save-op [1 0 :block/uuid])))
(is (= ""
(get-in restore-empty-save-op [1 0 :block/title])))))))))
(deftest undo-history-replace-empty-target-insert-restores-empty-target-with-insert-op-test
(testing "replace-empty-target insert inverse should delete inserted blocks and reinsert original empty target"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
empty-target-uuid (random-uuid)
inserted-root-uuid (random-uuid)
inserted-child-uuid (random-uuid)]
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(let [empty-target (d/entity @conn [:block/uuid empty-target-uuid])]
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid inserted-root-uuid
:block/title "insert root"}
{:block/uuid inserted-child-uuid
:block/title "insert child"
:block/parent [:block/uuid inserted-root-uuid]}]
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true}]]]
(local-tx-meta {:client-id "test-client"})))
(let [data (latest-undo-history-data)
inverse-ops (:db-sync/inverse-outliner-ops data)
delete-op (some #(when (= :delete-blocks (first %)) %) inverse-ops)
restore-empty-insert-op (some #(when (= :insert-blocks (first %)) %) inverse-ops)
restore-empty-save-op (some #(when (= :save-block (first %)) %) inverse-ops)
delete-ids (set (get-in delete-op [1 0]))]
(is (= :insert-blocks (ffirst (:db-sync/forward-outliner-ops data))))
(is (seq delete-ids))
(is (or (some? restore-empty-insert-op)
(some? restore-empty-save-op)))
(if restore-empty-insert-op
(do
(is (= empty-target-uuid
(get-in restore-empty-insert-op [1 0 0 :block/uuid])))
(is (= ""
(get-in restore-empty-insert-op [1 0 0 :block/title]))))
(do
(is (= empty-target-uuid
(get-in restore-empty-save-op [1 0 :block/uuid])))
(is (= ""
(get-in restore-empty-save-op [1 0 :block/title])))))))))
(deftest apply-template-op-replays-via-undo-redo-test
(testing ":apply-template op can be applied and replayed via undo/redo"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
template-root-uuid (random-uuid)
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
:block/tags #{:logseq.class/Template}}
{:block/uuid template-a-uuid
:block/title "a"
:block/parent [:block/uuid template-root-uuid]}
{:block/uuid template-b-uuid
:block/title "b"
:block/parent [:block/uuid template-a-uuid]}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(let [template-root (d/entity @conn [:block/uuid template-root-uuid])
empty-target (d/entity @conn [:block/uuid empty-target-uuid])
template-blocks (->> (ldb/get-block-and-children @conn template-root-uuid
{:include-property-block? true})
rest)
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))]
(outliner-op/apply-ops!
conn
[[:apply-template [(:db/id template-root)
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:template-blocks blocks-to-insert}]]]
(local-tx-meta {:client-id "test-client"})))
(let [data (latest-undo-history-data)]
(is (= :apply-template (ffirst (:db-sync/forward-outliner-ops data)))))
(is (seq (undo-all!)))
(is (seq (redo-all!)))
(let [inserted-a-id (d/q '[:find ?b .
:in $ ?template-uuid
:where
[?template :block/uuid ?template-uuid]
[?b :logseq.property/used-template ?template]
[?b :block/title "a"]]
@conn
template-root-uuid)
inserted-a (when inserted-a-id (d/entity @conn inserted-a-id))
inserted-b (some->> inserted-a :block/_parent (filter #(= "b" (:block/title %))) first)]
(is (some? inserted-a))
(is (some? inserted-b))))))
(deftest apply-template-repeated-undo-redo-uses-latest-history-tx-id-test
(testing ":apply-template repeated undo/redo should always undo latest recreated blocks"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
template-root-uuid (random-uuid)
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
:block/tags #{:logseq.class/Template}}
{:block/uuid template-a-uuid
:block/title "a"
:block/parent [:block/uuid template-root-uuid]}
{:block/uuid template-b-uuid
:block/title "b"
:block/parent [:block/uuid template-a-uuid]}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
page-id
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(worker-undo-redo/clear-history! test-repo)
(let [template-root (d/entity @conn [:block/uuid template-root-uuid])
empty-target (d/entity @conn [:block/uuid empty-target-uuid])
template-blocks (->> (ldb/get-block-and-children @conn template-root-uuid
{:include-property-block? true})
rest)
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))
find-inserted-a-id (fn []
(d/q '[:find ?b .
:in $ ?template-uuid
:where
[?template :block/uuid ?template-uuid]
[?b :logseq.property/used-template ?template]
[?b :block/title "a"]]
@conn
template-root-uuid))]
(outliner-op/apply-ops!
conn
[[:apply-template [(:db/id template-root)
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:template-blocks blocks-to-insert}]]]
(local-tx-meta {:client-id "test-client"}))
(is (some? (find-inserted-a-id)))
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(is (nil? (find-inserted-a-id)))
(is (not= ::worker-undo-redo/empty-redo-stack
(worker-undo-redo/redo test-repo)))
(let [redo-1-a-id (find-inserted-a-id)]
(is (some? redo-1-a-id))
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(is (nil? (find-inserted-a-id)))
(is (not= ::worker-undo-redo/empty-redo-stack
(worker-undo-redo/redo test-repo)))
(let [redo-2-a-id (find-inserted-a-id)]
(is (some? redo-2-a-id))
(is (not= redo-1-a-id redo-2-a-id))
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(is (nil? (find-inserted-a-id)))))))))
(deftest undo-history-records-forward-ops-for-save-block-test
(testing "worker save-block history keeps semantic forward ops for redo replay"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(outliner-op/apply-ops! conn
[[:save-block [{:block/uuid child-uuid
:block/title "saved via apply-ops"} {}]]]
(local-tx-meta {:client-id "test-client"}))
(let [undo-op (last (get @worker-undo-redo/*undo-ops test-repo))
data (some #(when (= ::worker-undo-redo/db-transact (first %))
(second %))
undo-op)]
(is (= :save-block (ffirst (:db-sync/forward-outliner-ops data))))
(is (= child-uuid
(get-in data [:db-sync/forward-outliner-ops 0 1 0 :block/uuid])))
(is (= "saved via apply-ops"
(get-in data [:db-sync/forward-outliner-ops 0 1 0 :block/title])))
(is (= "saved via apply-ops"
(:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(deftest undo-insert-retracts-added-entity-cleanly-test
(testing "undoing a local insert retracts the inserted entity"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)
page-id (:db/id (d/entity @conn [:block/uuid page-uuid]))
inserted-uuid (random-uuid)]
(d/transact! conn
[{:block/uuid inserted-uuid
:block/title "inserted"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]}]
(local-tx-meta
{:client-id "test-client"
:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/title "inserted"
:block/uuid inserted-uuid}]
page-id
{:sibling? false}]]]}))
(is (some? (d/entity @conn [:block/uuid inserted-uuid])))
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (map? undo-result))
(is (nil? (d/entity @conn [:block/uuid inserted-uuid])))))))
(deftest repeated-save-block-content-undo-redo-test
(testing "multiple saves on the same block undo and redo one step at a time"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(doseq [title ["v1" "v2" "v3"]]
(save-block-title! conn child-uuid title))
(is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/undo test-repo)
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/undo test-repo)
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/undo test-repo)
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/redo test-repo)
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/redo test-repo)
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/redo test-repo)
(is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))
(deftest repeated-save-block-op-content-undo-redo-test
(testing "sequential save-block ops preserve undo/redo order"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(doseq [title ["foo" "foo bar"]]
(outliner-op/apply-ops! conn
[[:save-block [{:block/uuid child-uuid
:block/title title} {}]]]
(local-tx-meta {:client-id "test-client"})))
(is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/undo test-repo)
(is (= "foo" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/redo test-repo)
(is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))
(deftest save-two-blocks-undo-targets-latest-block-test
(testing "undo after saving two blocks reverts the latest saved block first"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [parent-uuid child-uuid]} (seed-page-parent-child!)]
(save-block-title! conn parent-uuid "parent updated")
(save-block-title! conn child-uuid "child updated")
(worker-undo-redo/undo test-repo)
(is (= "parent updated" (:block/title (d/entity @conn [:block/uuid parent-uuid]))))
(is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(worker-undo-redo/undo test-repo)
(is (= "parent" (:block/title (d/entity @conn [:block/uuid parent-uuid])))))))
(deftest new-local-save-clears-redo-stack-test
(testing "a new local save clears redo history"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(save-block-title! conn child-uuid "v1")
(save-block-title! conn child-uuid "v2")
(worker-undo-redo/undo test-repo)
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
(save-block-title! conn child-uuid "v3")
(is (= ::worker-undo-redo/empty-redo-stack
(worker-undo-redo/redo test-repo)))
(is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))

View File

@@ -3569,7 +3569,7 @@ fdir@^6.5.0:
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fflate@^0.4.8:
fflate@^0.4.1:
version "0.4.8"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
@@ -6565,18 +6565,12 @@ postcss@^8.4.23, postcss@^8.5.8:
picocolors "^1.1.1"
source-map-js "^1.2.1"
posthog-js@1.141.0:
version "1.141.0"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.141.0.tgz#bf85c935aa6b12a87f73f576adb6dd943888e675"
integrity sha512-EuVCq86izPX7+1eD/o87lF1HalRD6Nk5735w+FKZJ5KAPwoQjr5FCaL2V8Ed36DyQQz4gQj+PEx5i6DFKCiDzA==
posthog-js@1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.10.0.tgz#4d86360161170d37c249482f016acac2f4b6d978"
integrity sha512-WbcPRRX62XTq2F2lbakuDK6/HPAJ43gkuPeM4vU/hoC7WICAc+gZJaXZFy8zY25r/5GZPWUhhW8KrbL0aZ11XQ==
dependencies:
fflate "^0.4.8"
preact "^10.19.3"
preact@^10.19.3:
version "10.29.0"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.0.tgz#a6e5858670b659c4d471c6fea232233e03b403e8"
integrity sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==
fflate "^0.4.1"
prebuild-install@^7.1.1:
version "7.1.3"