mirror of
https://github.com/logseq/logseq.git
synced 2026-05-23 12:14:06 +00:00
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:
2
deps/db-sync/.carve/ignore
vendored
2
deps/db-sync/.carve/ignore
vendored
@@ -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
|
||||
|
||||
12
deps/db-sync/README.md
vendored
12
deps/db-sync/README.md
vendored
@@ -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:
|
||||
|
||||
1
deps/db-sync/package.json
vendored
1
deps/db-sync/package.json
vendored
@@ -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",
|
||||
|
||||
85
deps/db-sync/src/logseq/db_sync/checksum.cljs
vendored
85
deps/db-sync/src/logseq/db_sync/checksum.cljs
vendored
@@ -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)))))
|
||||
|
||||
@@ -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
|
||||
|
||||
8
deps/db-sync/src/logseq/db_sync/storage.cljs
vendored
8
deps/db-sync/src/logseq/db_sync/storage.cljs
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
167
deps/db-sync/test/logseq/db_sync/checksum_test.cljs
vendored
167
deps/db-sync/test/logseq/db_sync/checksum_test.cljs
vendored
@@ -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)))))
|
||||
|
||||
@@ -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)))))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).`);
|
||||
|
||||
254
deps/db-sync/worker/scripts/delete_user_totally.js
vendored
Normal file
254
deps/db-sync/worker/scripts/delete_user_totally.js
vendored
Normal 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,
|
||||
};
|
||||
59
deps/db-sync/worker/scripts/delete_user_totally.test.js
vendored
Normal file
59
deps/db-sync/worker/scripts/delete_user_totally.test.js
vendored
Normal 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);
|
||||
});
|
||||
176
deps/db/src/logseq/db.cljs
vendored
176
deps/db/src/logseq/db.cljs
vendored
@@ -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]
|
||||
|
||||
6
deps/db/test/logseq/db_test.cljs
vendored
6
deps/db/test/logseq/db_test.cljs
vendored
@@ -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]]))))))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
2
deps/outliner/.carve/config.edn
vendored
2
deps/outliner/.carve/config.edn
vendored
@@ -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}}
|
||||
|
||||
5
deps/outliner/deps.edn
vendored
5
deps/outliner/deps.edn
vendored
@@ -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"}}
|
||||
|
||||
128
deps/outliner/src/logseq/outliner/core.cljs
vendored
128
deps/outliner/src/logseq/outliner/core.cljs
vendored
@@ -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)
|
||||
|
||||
151
deps/outliner/src/logseq/outliner/op.cljs
vendored
151
deps/outliner/src/logseq/outliner/op.cljs
vendored
@@ -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))
|
||||
|
||||
1045
deps/outliner/src/logseq/outliner/op/construct.cljc
vendored
Normal file
1045
deps/outliner/src/logseq/outliner/op/construct.cljc
vendored
Normal file
File diff suppressed because it is too large
Load Diff
141
deps/outliner/src/logseq/outliner/page.cljs
vendored
141
deps/outliner/src/logseq/outliner/page.cljs
vendored
@@ -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))]
|
||||
|
||||
707
deps/outliner/src/logseq/outliner/property.cljs
vendored
707
deps/outliner/src/logseq/outliner/property.cljs
vendored
@@ -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}))))))))
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)))
|
||||
|
||||
11
deps/outliner/src/logseq/outliner/tx_meta.cljs
vendored
Normal file
11
deps/outliner/src/logseq/outliner/tx_meta.cljs
vendored
Normal 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]))))
|
||||
@@ -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')))))
|
||||
|
||||
404
deps/outliner/test/logseq/outliner/op_construct_test.cljs
vendored
Normal file
404
deps/outliner/test/logseq/outliner/op_construct_test.cljs
vendored
Normal 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))))))
|
||||
@@ -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
|
||||
|
||||
@@ -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)])))))
|
||||
|
||||
147
docs/adr/0010-op-driven-client-rebase.md
Normal file
147
docs/adr/0010-op-driven-client-rebase.md
Normal 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`
|
||||
378
docs/adr/0011-undo-redo-semantic-inverse-op-persistence.md
Normal file
378
docs/adr/0011-undo-redo-semantic-inverse-op-persistence.md
Normal 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.
|
||||
308
docs/adr/0012-worker-owned-undo-redo.md
Normal file
308
docs/adr/0012-worker-owned-undo-redo.md
Normal 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 recorder’s 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.
|
||||
187
docs/adr/0013-worker-owned-undo-redo-test-ownership.md
Normal file
187
docs/adr/0013-worker-owned-undo-redo-test-ownership.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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))))))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)]])
|
||||
|
||||
@@ -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
|
||||
;; =============
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'))))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))))))))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]}]
|
||||
|
||||
@@ -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?)
|
||||
|
||||
@@ -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!
|
||||
[]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
173
src/main/frontend/undo_redo/debug_ui.cljs
Normal file
173
src/main/frontend/undo_redo/debug_ui.cljs
Normal 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')]))
|
||||
@@ -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}))
|
||||
|
||||
@@ -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')))))))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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!))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!
|
||||
|
||||
367
src/main/frontend/worker/undo_redo.cljs
Normal file
367
src/main/frontend/worker/undo_redo.cljs
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))}))))
|
||||
|
||||
107
src/test/frontend/handler/history_test.cljs
Normal file
107
src/test/frontend/handler/history_test.cljs
Normal 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)))))
|
||||
70
src/test/frontend/handler/user_test.cljs
Normal file
70
src/test/frontend/handler/user_test.cljs
Normal 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))))))
|
||||
@@ -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"
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
16
src/test/frontend/worker/db_listener_test.cljs
Normal file
16
src/test/frontend/worker/db_listener_test.cljs
Normal 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
@@ -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)))))))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)))))))
|
||||
|
||||
936
src/test/frontend/worker/undo_redo_test.cljs
Normal file
936
src/test/frontend/worker/undo_redo_test.cljs
Normal 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])))))))
|
||||
18
yarn.lock
18
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user