fix(db-sync): rebind redo history tx-id for undo replay

This commit is contained in:
Tienson Qin
2026-03-24 13:10:35 +08:00
parent e6a3c6a6e2
commit 7b746adbcb
9 changed files with 314 additions and 294 deletions

View File

@@ -143,16 +143,14 @@
(let [key (keyword "db-sync-sim" repo)]
(d/listen! conn key
(fn [tx-report]
(db-sync/enqueue-local-tx! 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?)))))))
(let [tx-report' (-> 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?))))]
(db-sync/enqueue-local-tx! repo tx-report'))))
(swap! listeners conj [conn key]))))
(try
(f)

View File

@@ -1010,80 +1010,6 @@
(finally
(reset! ldb/*transact-invalid-callback prev-invalid-callback))))))))
(deftest undo-redo-insert-save-insert-save-indent-sequence-keeps-block-valid-test
(testing "insert/save/insert/save/indent then undo-all/redo-all/undo keeps block 2 valid"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks [{:page {:block/title "page 1"}
:blocks []}]})
client-ops-conn (d/create-conn client-op/schema-in-db)
page-1 (db-test/find-page-by-title @conn "page 1")
page-id (:db/id page-1)
block-1-uuid (random-uuid)
block-2-uuid (random-uuid)
prev-invalid-callback @ldb/*transact-invalid-callback
invalid-payload* (atom nil)]
(with-datascript-conns conn client-ops-conn
(fn []
(d/listen! conn ::worker-undo-listener
(fn [tx-report]
(worker-undo-redo/gen-undo-ops! test-repo tx-report)))
(reset! ldb/*transact-invalid-callback
(fn [tx-report errors]
(reset! invalid-payload* {:tx-meta (:tx-meta tx-report)
:errors errors})))
(worker-undo-redo/clear-history! test-repo)
(try
(outliner-op/apply-ops! conn
[[:insert-blocks [[{:block/uuid block-1-uuid
:block/title ""}]
page-id
{:sibling? false
:keep-uuid? true}]]]
local-tx-meta)
(outliner-op/apply-ops! conn
[[:save-block [{:block/uuid block-1-uuid
:block/title "1"}
nil]]]
local-tx-meta)
(let [block-1 (d/entity @conn [:block/uuid block-1-uuid])]
(outliner-op/apply-ops! conn
[[:insert-blocks [[{:block/uuid block-2-uuid
:block/title ""}]
(:db/id block-1)
{:sibling? true
:keep-uuid? true}]]]
local-tx-meta))
(outliner-op/apply-ops! conn
[[:save-block [{:block/uuid block-2-uuid
:block/title "2"}
nil]]]
local-tx-meta)
(let [block-2 (d/entity @conn [:block/uuid block-2-uuid])]
(outliner-op/apply-ops! conn
[[:indent-outdent-blocks [[(:db/id block-2)] true {}]]]
local-tx-meta))
(loop []
(when-not (= :frontend.worker.undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo))
(recur)))
(loop []
(when-not (= :frontend.worker.undo-redo/empty-redo-stack
(worker-undo-redo/redo test-repo))
(recur)))
(is (not= :frontend.worker.undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(let [block-2 (d/entity @conn [:block/uuid block-2-uuid])]
(is (some? block-2))
(is (= "2" (:block/title block-2)))
(is (= (:block/uuid page-1) (-> block-2 :block/page :block/uuid)))
(is (= (:block/uuid page-1) (-> block-2 :block/parent :block/uuid))))
(is (nil? @invalid-payload*))
(finally
(d/unlisten! conn ::worker-undo-listener)
(worker-undo-redo/clear-history! test-repo)
(reset! ldb/*transact-invalid-callback prev-invalid-callback))))))))
(deftest enqueue-local-tx-canonicalizes-batch-import-to-transact-test
(testing "batch-import-edn local tx persists as canonical transact op"
(let [{:keys [conn client-ops-conn]} (setup-parent-child)
@@ -1126,11 +1052,13 @@
:block/title "hello"} nil]]]
local-tx-meta)
(let [{:keys [tx-id]} (first (#'sync-apply/pending-txs test-repo))]
(is (= true
(:applied? (#'sync-apply/apply-history-action! test-repo
tx-id
true
{:db-sync/tx-id tx-id}))))
(let [{:keys [applied? history-tx-id]} (#'sync-apply/apply-history-action! test-repo
tx-id
true
{:db-sync/tx-id tx-id})]
(is (= true applied?))
(is (uuid? history-tx-id))
(is (not= tx-id history-tx-id)))
(let [pending (#'sync-apply/pending-txs test-repo)]
(is (= 2 (count pending)))
(is (= 2 (count (distinct (map :tx-id pending)))))
@@ -1138,6 +1066,59 @@
(get-in (#'sync-apply/pending-tx-by-id test-repo tx-id)
[:forward-outliner-ops 0 1 0 :block/title]))))))))))
(deftest apply-history-action-preserves-source-forward-inverse-ops-test
(testing "undo/redo history actions should preserve source forward/inverse ops and create new tx rows"
(let [{:keys [conn client-ops-conn child1]} (setup-parent-child)
child-uuid (:block/uuid child1)]
(with-datascript-conns conn client-ops-conn
(fn []
(outliner-op/apply-ops! conn
[[:save-block [{:block/uuid child-uuid
:block/title "hello"} nil]]]
local-tx-meta)
(let [{source-tx-id :tx-id} (first (#'sync-apply/pending-txs test-repo))]
(let [{undo-applied? :applied?
undo-history-tx-id :history-tx-id}
(#'sync-apply/apply-history-action! test-repo
source-tx-id
true
{})]
(is (= true undo-applied?))
(is (uuid? undo-history-tx-id))
(is (not= source-tx-id undo-history-tx-id)))
(let [source-pending (#'sync-apply/pending-tx-by-id test-repo source-tx-id)
pending-after-undo (#'sync-apply/pending-txs test-repo)
undo-pending (first (filter #(not= source-tx-id (:tx-id %)) pending-after-undo))]
(is (= 2 (count pending-after-undo)))
(is (some? undo-pending))
(is (= "hello"
(get-in source-pending [:forward-outliner-ops 0 1 0 :block/title])))
(is (= "child 1"
(get-in source-pending [:inverse-outliner-ops 0 1 0 :block/title])))
(is (= "child 1"
(get-in undo-pending [:forward-outliner-ops 0 1 0 :block/title])))
(is (= "hello"
(get-in undo-pending [:inverse-outliner-ops 0 1 0 :block/title]))))
(let [{redo-applied? :applied?
redo-history-tx-id :history-tx-id}
(#'sync-apply/apply-history-action! test-repo
source-tx-id
false
{})]
(is (= true redo-applied?))
(is (uuid? redo-history-tx-id))
(is (not= source-tx-id redo-history-tx-id)))
(let [source-pending (#'sync-apply/pending-tx-by-id test-repo source-tx-id)
pending-after-redo (#'sync-apply/pending-txs test-repo)
new-tx-ids (set (map :tx-id pending-after-redo))]
(is (= 3 (count pending-after-redo)))
(is (= 3 (count new-tx-ids)))
(is (contains? new-tx-ids source-tx-id))
(is (= "hello"
(get-in source-pending [:forward-outliner-ops 0 1 0 :block/title])))
(is (= "child 1"
(get-in source-pending [:inverse-outliner-ops 0 1 0 :block/title]))))))))))
(deftest apply-history-action-semantic-op-must-not-fallback-to-raw-tx-test
(testing "semantic history action should not fallback to raw tx replay"
(let [{:keys [conn client-ops-conn child1]} (setup-parent-child)

View File

@@ -34,8 +34,7 @@
(reset! worker-state/*client-ops-conns {test-repo client-ops-conn})
(d/listen! conn ::gen-undo-ops
(fn [tx-report]
(db-sync/enqueue-local-tx! test-repo tx-report)
(worker-undo-redo/gen-undo-ops! test-repo tx-report)))
(db-sync/enqueue-local-tx! test-repo tx-report)))
(worker-undo-redo/clear-history! test-repo)
(try
(f)
@@ -47,27 +46,6 @@
(use-fixtures :each with-worker-conns)
(deftest gen-undo-ops-consumes-pending-editor-info-test
(let [conn (worker-state/get-datascript-conn test-repo)
block (db-test/find-block-by-content @conn "task")
block-uuid (:block/uuid block)
tx-report (d/with @conn
[[:db/add (:db/id block) :block/title "updated task"]]
(local-tx-meta
{:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid block-uuid
:block/title "updated task"} nil]]]}))
editor-info {:block-uuid block-uuid
:container-id 1
:start-pos 0
:end-pos 7}]
(worker-undo-redo/set-pending-editor-info! test-repo editor-info)
(worker-undo-redo/gen-undo-ops! test-repo tx-report)
(let [op (last (get @worker-undo-redo/*undo-ops test-repo))]
(is (= [::worker-undo-redo/record-editor-info editor-info]
(first op)))
(is (nil? (get @worker-undo-redo/*pending-editor-info test-repo))))))
(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)
@@ -130,6 +108,13 @@
(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-clears-history-test
(testing "worker undo treats missing tx-id action row as unavailable and clears history"
(worker-undo-redo/clear-history! test-repo)
@@ -153,6 +138,27 @@
(is (= ::worker-undo-redo/empty-redo-stack redo-result))
(is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(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)
@@ -550,6 +556,85 @@
(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)