enhance(recycle): permanently delete recycled roots with sync-safe replay

This commit is contained in:
Tienson Qin
2026-04-09 04:01:48 +08:00
parent 9362dbc847
commit 801620b901
10 changed files with 185 additions and 9 deletions

View File

@@ -8,6 +8,7 @@
[logseq.outliner.core :as outliner-core]
[logseq.outliner.page :as outliner-page]
[logseq.outliner.property :as outliner-property]
[logseq.outliner.recycle :as outliner-recycle]
[logseq.outliner.transaction :as outliner-tx]
[malli.core :as m]))
@@ -128,6 +129,11 @@
[:op :keyword]
[:args [:tuple ::uuid ::option]]]]
[:recycle-delete-permanently
[:catn
[:op :keyword]
[:args [:tuple ::uuid]]]]
[:toggle-reaction
[:catn
[:op :keyword]
@@ -346,6 +352,10 @@
(let [[page-uuid opts] args]
(outliner-page/delete! conn page-uuid (merge opts opts')))
:recycle-delete-permanently
(let [[root-uuid] args]
(outliner-recycle/permanently-delete! conn root-uuid))
:toggle-reaction
(reset! *result (apply toggle-reaction! conn args))
nil))

View File

@@ -22,6 +22,7 @@
:rename-page
:delete-page
:restore-recycled
:recycle-delete-permanently
:set-block-property
:remove-block-property
:batch-set-property
@@ -441,6 +442,10 @@
(let [[root-id] args]
[:restore-recycled [root-id]])
:recycle-delete-permanently
(let [[root-id] args]
[:recycle-delete-permanently [(stable-entity-ref db root-id)]])
:set-block-property
(let [[block-eid property-id v] args]
[:set-block-property [(stable-entity-ref db block-eid)

View File

@@ -229,6 +229,26 @@
(ldb/transact! conn tx-data {:outliner-op :restore-recycled})
true)))
(defn ^:api permanently-delete-tx-data
[db root]
(when (and root (recycled? root))
(->> (if (ldb/page? root)
(keep (fn [id]
(some-> (d/entity db id) :block/uuid))
(page-tree-ids db root))
(keep :block/uuid (block-subtree db root)))
(map (fn [block-uuid]
[:db/retractEntity [:block/uuid block-uuid]]))
distinct
seq)))
(defn ^:api permanently-delete!
[conn root-uuid]
(when-let [root (d/entity @conn [:block/uuid root-uuid])]
(when-let [tx-data (permanently-delete-tx-data @conn root)]
(ldb/transact! conn tx-data {:outliner-op :recycle-delete-permanently})
true)))
(defn- gc-tx-data
[db {:keys [now-ms] :or {now-ms (common-util/time-ms)}}]
(let [cutoff (- now-ms retention-ms)]

View File

@@ -1,5 +1,6 @@
(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.recycle :as recycle]))
@@ -16,3 +17,34 @@
(is (nil? (:block/parent page')))
(is (nil? (:logseq.property/deleted-at page')))
(is (nil? (:logseq.property.recycle/original-parent page'))))))
(deftest permanently-delete-recycled-page-removes-page-and-descendants
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
block (db-test/find-block-by-content @conn "b1")
page-uuid (:block/uuid page)
block-uuid (:block/uuid block)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(is (true? (recycle/permanently-delete! conn page-uuid)))
(is (nil? (d/entity @conn [:block/uuid page-uuid])))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-block-removes-subtree-only
(let [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")
child (db-test/find-block-by-content @conn "child")
parent-uuid (:block/uuid parent)
child-uuid (:block/uuid child)]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [parent] {}) {:outliner-op :delete-blocks})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid parent-uuid]))))
(is (true? (recycle/permanently-delete! conn parent-uuid)))
(is (some? (d/entity @conn [:block/uuid (:block/uuid page)])))
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))

View File

@@ -4,9 +4,12 @@
[datascript.core :as d]
[frontend.components.block :as component-block]
[frontend.db :as db]
[frontend.db-mixins :as db-mixins]
[frontend.db.react :as react]
[frontend.handler.editor :as editor-handler]
[frontend.handler.page :as page-handler]
[frontend.state :as state]
[frontend.util :as util]
[logseq.db :as ldb]
[logseq.shui.ui :as shui]
[rum.core :as rum]))
@@ -36,6 +39,20 @@
(map #(d/entity db %))
(sort-by :logseq.property/deleted-at #(compare %2 %1))))
(defn- sub-deleted-root-ids
[]
(when-let [repo (state/get-current-repo)]
(some-> (react/q repo
[:frontend.worker.react/recycle-roots]
{:query-fn (fn [db _]
(->> (d/q '[:find [?e ...]
:where
[?e :logseq.property/deleted-at]]
db)
vec))}
nil)
util/react)))
(defn- group-title
[db root]
(if (ldb/page? root)
@@ -59,7 +76,11 @@
(defn- deleted-root-header
[db root]
(let [user (deleted-by db root)
deleted-at (:logseq.property/deleted-at root)]
deleted-at (:logseq.property/deleted-at root)
root-uuid (:block/uuid root)
delete-message (str "Permanently delete this "
(if (ldb/page? root) "page" "block")
" from Recycle? This cannot be undone.")]
[:div.flex.items-center.justify-between.gap-4.text-xs.text-muted-foreground
[:div.flex.items-center.gap-1.min-w-0.flex-1
(deleted-by-avatar user)
@@ -68,12 +89,20 @@
(str (if (ldb/page? root) "Page" "Block")
" deleted "
(.toLocaleString (js/Date. deleted-at)))]]]
(shui/button
{:variant :ghost
:size :xs
:class "!py-0 !px-1 h-4"
:on-click #(page-handler/restore-recycled! (:block/uuid root))}
"Restore")]))
[:div.flex.items-center.gap-1
(shui/button
{:variant :ghost
:size :xs
:class "!py-0 !px-1 h-4"
:on-click #(page-handler/restore-recycled! root-uuid)}
"Restore")
(shui/button
{:variant :ghost
:size :xs
:class "!py-0 !px-1 h-4 hover:text-destructive"
:on-click #(when (js/confirm delete-message)
(page-handler/delete-recycled-permanently! root-uuid))}
"Delete permanently")]]))
(defn- deleted-root-outliner
[root]
@@ -88,10 +117,17 @@
:id (str (:block/uuid root))}
root))
(rum/defc recycle-page
(rum/defc recycle-page < rum/reactive db-mixins/query
[_page]
(let [db* (db/get-db)
groups (->> (deleted-roots db*)
root-ids (or (sub-deleted-root-ids)
[])
roots (if (seq root-ids)
(->> root-ids
(keep #(d/entity db* %))
(sort-by :logseq.property/deleted-at #(compare %2 %1)))
(deleted-roots db*))
groups (->> roots
(group-by #(group-title db* %))
(sort-by (fn [[_ roots]]
(:logseq.property/deleted-at (first roots)))

View File

@@ -57,6 +57,16 @@
(outliner-op/transact! tx-data nil))
true))))
(defn delete-recycled-permanently!
[root-uuid]
(when-let [root (db/entity [:block/uuid root-uuid])]
(when-let [tx-data (seq (outliner-recycle/permanently-delete-tx-data (db/get-db) root))]
(p/do!
(ui-outliner-tx/transact!
{:outliner-op :recycle-delete-permanently}
(outliner-op/recycle-delete-permanently! root-uuid))
true))))
(defn <unfavorite-page!
[page-name]
(p/do!

View File

@@ -163,3 +163,8 @@
([page-uuid opts]
(op-transact!
[:delete-page [page-uuid (current-user-delete-opts opts)]])))
(defn recycle-delete-permanently!
[root-uuid]
(op-transact!
[:recycle-delete-permanently [root-uuid]]))

View File

@@ -19,6 +19,8 @@
(s/def ::objects (s/tuple #(= ::objects %) int?))
;; get block reactions
(s/def ::block-reactions (s/tuple #(= ::block-reactions %) int?))
;; recycle roots list
(s/def ::recycle-roots (s/tuple #(= ::recycle-roots %)))
;; custom react-query
(s/def ::custom any?)
@@ -27,6 +29,7 @@
:refs ::refs
:objects ::objects
:block-reactions ::block-reactions
:recycle-roots ::recycle-roots
:custom ::custom))
(s/def ::affected-keys (s/coll-of ::react-query-keys))
@@ -71,6 +74,9 @@
(= :logseq.property.reaction/target (:a datom))) tx-data)
(map :v)
(distinct))
recycle-roots? (some (fn [datom]
(= :logseq.property/deleted-at (:a datom)))
tx-data)
other-blocks (->> (filter (fn [datom] (= "block" (namespace (:a datom)))) tx-data)
(map :e))
blocks (-> (concat blocks other-blocks) distinct)
@@ -114,6 +120,9 @@
(when tag [::objects tag]))
tags)
(when recycle-roots?
[[::recycle-roots]])
(when journals?
[[::journals]]))]
(->>

View File

@@ -800,6 +800,27 @@
(ldb/transact! conn tx-data
{:outliner-op :restore-recycled}))
:recycle-delete-permanently
(let [[root-id] args
root-ref (cond
(and (vector? root-id)
(= :block/uuid (first root-id)))
root-id
(uuid? root-id)
[:block/uuid root-id]
:else
root-id)
root (d/entity @conn root-ref)
tx-data (when root
(seq (outliner-recycle/permanently-delete-tx-data @conn root)))]
;; Keep replay idempotent under concurrent edits where the recycled root may
;; already be permanently removed by a preceding remote tx.
(when (seq tx-data)
(ldb/transact! conn tx-data
{:outliner-op :recycle-delete-permanently})))
:set-block-property
(let [[block-eid property-id v] args
block-eid' (or (replay-entity-id-value @conn block-eid)

View File

@@ -1776,6 +1776,34 @@
(is (= #{"page y"}
(set (map :block/name (:user.property/x7 block'))))))))))
(deftest replay-recycle-delete-permanently-removes-recycled-page-test
(testing "replay should permanently delete a recycled page subtree"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page 1"}
:blocks [{:block/title "child 1"}]}])
page (db-test/find-page-by-title @conn "page 1")
child (db-test/find-block-by-content @conn "child 1")
page-uuid (:block/uuid page)
child-uuid (:block/uuid child)]
(outliner-page/delete! conn page-uuid {})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(is (nil? (#'sync-apply/replay-canonical-outliner-op!
conn
[:recycle-delete-permanently [[:block/uuid page-uuid]]]
nil)))
(is (nil? (d/entity @conn [:block/uuid page-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid]))))))
(deftest replay-recycle-delete-permanently-missing-root-is-idempotent-test
(testing "replay should no-op when recycled root has already been removed"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page 1"}}])
missing-uuid (random-uuid)]
(is (nil? (#'sync-apply/replay-canonical-outliner-op!
conn
[:recycle-delete-permanently [[:block/uuid missing-uuid]]]
nil))))))
(deftest replay-set-block-property-converts-raw-uuid-to-eid-test
(testing "replay should resolve raw block uuid ids for set-block-property"
(let [graph {:classes {:tag1 {}}