Tienson Qin пре 1 недеља
родитељ
комит
2fe0de2271

+ 323 - 0
clj-e2e/test/logseq/e2e/rtc_extra_part2_test.clj

@@ -1,11 +1,14 @@
 (ns logseq.e2e.rtc-extra-part2-test
   (:require [clojure.java.io :as io]
+            [clojure.string :as string]
             [clojure.test :refer [deftest testing is use-fixtures run-test]]
             [jsonista.core :as json]
             [logseq.e2e.block :as b]
             [logseq.e2e.const :refer [*page1 *page2 *graph-name*]]
+            [logseq.e2e.custom-report :as custom-report]
             [logseq.e2e.fixtures :as fixtures]
             [logseq.e2e.graph :as graph]
+            [logseq.e2e.keyboard :as k]
             [logseq.e2e.page :as page]
             [logseq.e2e.rtc :as rtc]
             [logseq.e2e.util :as util]
@@ -19,6 +22,326 @@
 (use-fixtures :each
   fixtures/new-logseq-page-in-rtc)
 
+(def ^:private stress-default-rounds 1)
+(def ^:private stress-default-ops-per-client 50)
+(def ^:private stress-default-seed-blocks 20)
+(def ^:private stress-default-seed 20260330)
+(def ^:private stress-max-seed-depth 4)
+(def ^:private severe-sync-log-patterns
+  ["db-sync/checksum-mismatch"
+   "db-sync/tx-rejected"
+   "db-sync/apply-remote-txs-failed"])
+(def ^:private random-edit-actions
+  [:new :save :indent-outdent :delete-existing :undo :redo])
+
+(defn- env-int
+  [k default]
+  (let [raw (System/getenv k)]
+    (if-not (string/blank? raw)
+      (try
+        (Integer/parseInt raw)
+        (catch Throwable _
+          default))
+      default)))
+
+(defn- recent-console-logs
+  []
+  (->> (some-> custom-report/*pw-page->console-logs* deref vals)
+       (mapcat identity)
+       vec))
+
+(defn- assert-no-severe-sync-errors!
+  []
+  (let [logs (recent-console-logs)
+        matched (->> logs
+                     (filter (fn [line]
+                               (some #(string/includes? line %) severe-sync-log-patterns)))
+                     vec)]
+    (is (empty? matched)
+        (str "found severe sync errors in console logs: "
+             (pr-str (take-last 20 matched))))))
+
+(defn- page-sync-state
+  [pw-page]
+  (w/with-page pw-page
+    (util/exit-edit)
+    {:rtc-tx (rtc/get-rtc-tx)
+     :blocks (util/get-page-blocks-contents)}))
+
+(defn- assert-two-pages-synced!
+  []
+  (let [s1 (page-sync-state @*page1)
+        s2 (page-sync-state @*page2)
+        tx1 (:rtc-tx s1)
+        tx2 (:rtc-tx s2)]
+    (is (= (:blocks s1) (:blocks s2))
+        (str "page blocks diverged: "
+             (pr-str {:page1-count (count (:blocks s1))
+                      :page2-count (count (:blocks s2))
+                      :page1-tail (take-last 8 (:blocks s1))
+                      :page2-tail (take-last 8 (:blocks s2))})))
+    (is (= (:local-tx tx1) (:remote-tx tx1))
+        (str "page1 rtc-tx not converged: " (pr-str tx1)))
+    (is (= (:local-tx tx2) (:remote-tx tx2))
+        (str "page2 rtc-tx not converged: " (pr-str tx2)))))
+
+(defn- try-indent!
+  []
+  (if-let [editor (util/get-editor)]
+    (let [[x1 _] (util/bounding-xy editor)]
+      (k/tab)
+      (if-let [editor' (util/get-editor)]
+        (let [[x2 _] (util/bounding-xy editor')]
+          (> x2 x1))
+        false))
+    false))
+
+(defn- try-outdent!
+  []
+  (if-let [editor (util/get-editor)]
+    (let [[x1 _] (util/bounding-xy editor)]
+      (k/shift+tab)
+      (if-let [editor' (util/get-editor)]
+        (let [[x2 _] (util/bounding-xy editor')]
+          (> x1 x2))
+        false))
+    false))
+
+(defn- align-depth!
+  [depth target]
+  (loop [d depth]
+    (cond
+      (< d target) (if (try-indent!)
+                     (recur (inc d))
+                     d)
+      (> d target) (if (try-outdent!)
+                     (recur (dec d))
+                     d)
+      :else d)))
+
+(defn- new-block-safe!
+  [title]
+  (loop [attempt 4]
+    (let [created?
+          (try
+            (b/new-block title)
+            true
+            (catch Throwable _
+              false))]
+      (if created?
+        true
+        (if (zero? attempt)
+          (throw (ex-info "new-block-safe failed" {:title title}))
+          (do
+            (util/exit-edit)
+            (util/wait-timeout 80)
+            (try
+              (b/open-last-block)
+              (catch Throwable _
+                nil))
+            (util/wait-timeout 80)
+            (recur (dec attempt))))))))
+
+(defn- sync-by-trigger!
+  ([tag]
+   (sync-by-trigger! tag nil))
+  ([tag checkpoints]
+   (let [target-tx (some->> checkpoints
+                            vals
+                            (filter integer?)
+                            seq
+                            (apply max))]
+     ;; Ensure both pages have observed all prior edit/undo-redo txs first.
+     (when target-tx
+       (w/with-page @*page1
+         (rtc/wait-tx-update-to target-tx))
+       (w/with-page @*page2
+         (rtc/wait-tx-update-to target-tx)))
+     (let [{:keys [remote-tx]}
+           (w/with-page @*page1
+             (rtc/with-wait-tx-updated
+               (new-block-safe! (str "sync-trigger-" tag))))]
+       (w/with-page @*page1
+         (rtc/wait-tx-update-to remote-tx))
+       (w/with-page @*page2
+         (rtc/wait-tx-update-to remote-tx))))))
+
+(defn- seed-long-nested-page!
+  [seed]
+  (let [seed-blocks (max 20 (env-int "DB_SYNC_E2E_STRESS_SEED_BLOCKS" stress-default-seed-blocks))
+        rng (java.util.Random. (long (+ seed 97)))]
+    (let [titles
+          (w/with-page @*page1
+            (util/exit-edit)
+            (loop [i 0
+                   depth 0
+                   titles #{}]
+              (if (< i seed-blocks)
+                (let [title (format "seed-r%s-%03d" seed i)
+                      target-depth (.nextInt rng (inc stress-max-seed-depth))]
+                  (new-block-safe! title)
+                  (recur (inc i)
+                         (align-depth! depth target-depth)
+                         (conj titles title)))
+                (do
+                  (util/exit-edit)
+                  titles))))]
+      (sync-by-trigger! (str "seed-" seed))
+      titles)))
+
+(defn- next-action
+  [rng]
+  (nth random-edit-actions
+       (.nextInt rng (count random-edit-actions))))
+
+(defn- delete-existing-random-block!
+  [rng known-titles]
+  (loop [attempt 8]
+    (if (zero? attempt)
+      0
+      (let [titles (vec @known-titles)]
+        (if (empty? titles)
+          0
+          (let [title (nth titles (.nextInt rng (count titles)))
+                deleted?
+                (try
+                  (b/jump-to-block title)
+                  (b/delete-blocks)
+                  true
+                  (catch Throwable _
+                    false))]
+            (if deleted?
+              (do
+                (swap! known-titles disj title)
+                1)
+              (recur (dec attempt)))))))))
+
+(defn- random-edit-op!
+  [rng known-titles client-prefix round op-idx]
+  (let [base (format "%s-r%s-op%s" client-prefix round op-idx)]
+    (case (next-action rng)
+      :new
+      (let [title (str base "-new")]
+        (new-block-safe! title)
+        (swap! known-titles conj title)
+        1)
+
+      :save
+      (let [save-title (str base "-save-updated")]
+        (new-block-safe! (str base "-save"))
+        (b/save-block save-title)
+        (swap! known-titles conj save-title)
+        2)
+
+      :indent-outdent
+      (let [title (str base "-nest")]
+        (new-block-safe! title)
+        (swap! known-titles conj title)
+        (+ 1
+           (if (try-indent!) 1 0)
+           (if (try-outdent!) 1 0)))
+
+      :delete-existing
+      (delete-existing-random-block! rng known-titles)
+
+      :undo
+      (do
+        (b/undo)
+        0)
+
+      :redo
+      (do
+        (b/redo)
+        0))))
+
+(defn- local-random-edit-batch!
+  [rng known-titles client-prefix round]
+  (let [ops (max 1 (env-int "DB_SYNC_E2E_STRESS_OPS_PER_CLIENT" stress-default-ops-per-client))]
+    (loop [i 0
+           undo-steps 0]
+      (if (< i ops)
+        (recur (inc i)
+               (+ undo-steps
+                  (random-edit-op! rng known-titles client-prefix round i)))
+        (do
+          (util/exit-edit)
+          undo-steps)))))
+
+(defn- local-undo-redo-batch!
+  [undo-steps]
+  (let [steps (max 1 undo-steps)]
+    ;; Undo and redo exactly what this client edited in the current round.
+    (b/open-last-block)
+    (dotimes [_ steps]
+      (b/undo))
+    (dotimes [_ steps]
+      (b/redo))
+    (util/exit-edit)))
+
+(def ^:private stress-client-op-timeout-ms 120000)
+
+(defn- await-future!
+  [f label]
+  (let [result (deref f stress-client-op-timeout-ms ::timeout)]
+    (when (= result ::timeout)
+      (throw (ex-info "parallel client op timed out"
+                      {:label label
+                       :timeout-ms stress-client-op-timeout-ms})))
+    result))
+
+(defn- run-two-clients-in-parallel!
+  [p1-fn p2-fn]
+  (let [start-signal (promise)
+        p1-future (future @start-signal (p1-fn))
+        p2-future (future @start-signal (p2-fn))]
+    (deliver start-signal true)
+    [(await-future! p1-future :p1-op)
+     (await-future! p2-future :p2-op)]))
+
+(deftest online-two-clients-undo-redo-stress-test
+  (testing "two online RTC clients survive random edits + undo/redo loops on a long nested page"
+    (let [rounds (max 1 (env-int "DB_SYNC_E2E_STRESS_ROUNDS" stress-default-rounds))
+          seed (long (env-int "DB_SYNC_E2E_STRESS_SEED" stress-default-seed))
+          p1-rng (java.util.Random. (long (+ seed 101)))
+          p2-rng (java.util.Random. (long (+ seed 202)))
+          known-titles (atom (seed-long-nested-page! seed))]
+      (dotimes [round rounds]
+        (let [p1-undo-steps (atom 0)
+              p2-undo-steps (atom 0)
+              ;; Phase 1: edit batches in parallel with synchronized start.
+              [_ _]
+              (run-two-clients-in-parallel!
+               #(w/with-page @*page1
+                  (reset! p1-undo-steps
+                          (local-random-edit-batch! p1-rng known-titles "p1" round)))
+               #(w/with-page @*page2
+                  (reset! p2-undo-steps
+                          (local-random-edit-batch! p2-rng known-titles "p2" round))))
+              p1-edit-remote-tx (w/with-page @*page1
+                                  (-> (rtc/get-rtc-tx) :local-tx))
+              p2-edit-remote-tx (w/with-page @*page2
+                                  (-> (rtc/get-rtc-tx) :local-tx))
+              ;; Phase 2: undo+redo batches in parallel with synchronized start.
+              [_ _]
+              (run-two-clients-in-parallel!
+               #(w/with-page @*page1
+                  (local-undo-redo-batch! @p1-undo-steps))
+               #(w/with-page @*page2
+                  (local-undo-redo-batch! @p2-undo-steps)))
+              p1-undo-remote-tx (w/with-page @*page1
+                                  (-> (rtc/get-rtc-tx) :local-tx))
+              p2-undo-remote-tx (w/with-page @*page2
+                                  (-> (rtc/get-rtc-tx) :local-tx))]
+
+          (sync-by-trigger!
+           round
+           {:p1-edit p1-edit-remote-tx
+            :p2-edit p2-edit-remote-tx
+            :p1-undo p1-undo-remote-tx
+            :p2-undo p2-undo-remote-tx})
+          (assert-two-pages-synced!)
+          (assert-no-severe-sync-errors!))))))
+
 ;;; https://github.com/logseq/db-test/issues/651
 (deftest issue-651-block-title-double-transit-encoded-test
   (testing "

+ 127 - 16
src/test/frontend/worker/db_sync_sim_test.cljs

@@ -1520,7 +1520,18 @@
       (with-test-repos {repo-a {:conn conn-a :ops-conn ops-a}
                         repo-b {:conn conn-b :ops-conn ops-b}}
         (fn []
-          (let [{:keys [repro restore]} (install-invalid-tx-repro! seed history)]
+          (let [{:keys [repro restore]} (install-invalid-tx-repro! seed history)
+                listener-a ::checksum-sync-a
+                listener-b ::checksum-sync-b
+                update-local-checksum!
+                (fn [repo conn listener-key]
+                  (d/listen! conn listener-key
+                             (fn [tx-report]
+                               (when-not (:batch-tx? @conn)
+                                 (when (seq (:tx-data tx-report))
+                                   (db-sync/update-local-sync-checksum! repo tx-report))))))]
+            (update-local-checksum! repo-a conn-a listener-a)
+            (update-local-checksum! repo-b conn-b listener-b)
             (try
               (reset! db-sync/*repo->latest-remote-tx {})
               (record-meta! history {:seed seed :base-uuid base-uuid})
@@ -1528,6 +1539,8 @@
                 (ensure-base-page! conn base-uuid))
               (doseq [repo [repo-a repo-b]]
                 (client-op/update-local-tx repo 0))
+              (client-op/update-local-checksum repo-a (sync-checksum/recompute-checksum @conn-a))
+              (client-op/update-local-checksum repo-b (sync-checksum/recompute-checksum @conn-b))
 
               ;; Seed stable anchors (non-empty titles) that A won't touch.
               (let [base-a (d/entity @conn-a [:block/uuid base-uuid])
@@ -1618,6 +1631,10 @@
                   (is (empty? issues-a) (str "db A issues seed=" seed " " (pr-str issues-a)))
                   (is (empty? issues-b) (str "db B issues seed=" seed " " (pr-str issues-b)))
                   (assert-synced-attrs! seed history attrs-a attrs-b attrs-b)
+                  (is (= (sync-checksum/recompute-checksum @conn-a)
+                         (client-op/get-local-checksum repo-a)))
+                  (is (= (sync-checksum/recompute-checksum @conn-b)
+                         (client-op/get-local-checksum repo-b)))
                   (doseq [anchor-uuid anchor-uuids]
                     (let [ent-a (d/entity @conn-a [:block/uuid anchor-uuid])
                           ent-b (d/entity @conn-b [:block/uuid anchor-uuid])]
@@ -1627,6 +1644,8 @@
                       (is (not (string/blank? (or (:block/title ent-b) ""))) (str "anchor title blank in B seed=" seed " uuid=" anchor-uuid))))
                   (assert-no-invalid-tx! seed history repro)))
               (finally
+                (d/unlisten! conn-a listener-a)
+                (d/unlisten! conn-b listener-b)
                 (restore)))))))))
 
 (deftest two-clients-rebase-keeps-local-title-after-reverse-tx-test
@@ -1643,21 +1662,42 @@
       (with-test-repos {repo-a {:conn conn-a :ops-conn ops-a}
                         repo-b {:conn conn-b :ops-conn ops-b}}
         (fn []
-          (reset! db-sync/*repo->latest-remote-tx {})
-          (client-op/update-local-tx repo-a 0)
-          (client-op/update-local-tx repo-b 0)
-          (ensure-base-page! conn-a base-uuid)
-          (let [base (d/entity @conn-a [:block/uuid base-uuid])]
-            (create-block! conn-a base "before" block-uuid))
-          (sync-loop! server [{:repo repo-a :conn conn-a :client client-a :online? true}])
-          (sync-loop! server [{:repo repo-b :conn conn-b :client client-b :online? true}])
-          (is (= "before" (:block/title (d/entity @conn-b [:block/uuid block-uuid]))))
-          (update-title! conn-a block-uuid "test")
-          (is (seq (#'sync-apply/pending-txs repo-a)))
-          (d/transact! conn-b [[:db/add [:block/uuid block-uuid] :block/updated-at 1710000000000]])
-          (sync-loop! server [{:repo repo-b :conn conn-b :client client-b :online? true}])
-          (sync-loop! server [{:repo repo-a :conn conn-a :client client-a :online? true}])
-          (is (= "test" (:block/title (d/entity @conn-a [:block/uuid block-uuid])))))))))
+          (let [listener-a ::checksum-sync-a
+                listener-b ::checksum-sync-b
+                update-local-checksum!
+                (fn [repo conn]
+                  (d/listen! conn (if (= repo repo-a) listener-a listener-b)
+                             (fn [tx-report]
+                               (when-not (:batch-tx? @conn)
+                                 (when (seq (:tx-data tx-report))
+                                   (db-sync/update-local-sync-checksum! repo tx-report))))))]
+            (update-local-checksum! repo-a conn-a)
+            (update-local-checksum! repo-b conn-b)
+            (try
+              (reset! db-sync/*repo->latest-remote-tx {})
+              (client-op/update-local-tx repo-a 0)
+              (client-op/update-local-tx repo-b 0)
+              (client-op/update-local-checksum repo-a (sync-checksum/recompute-checksum @conn-a))
+              (client-op/update-local-checksum repo-b (sync-checksum/recompute-checksum @conn-b))
+              (ensure-base-page! conn-a base-uuid)
+              (let [base (d/entity @conn-a [:block/uuid base-uuid])]
+                (create-block! conn-a base "before" block-uuid))
+              (sync-loop! server [{:repo repo-a :conn conn-a :client client-a :online? true}])
+              (sync-loop! server [{:repo repo-b :conn conn-b :client client-b :online? true}])
+              (is (= "before" (:block/title (d/entity @conn-b [:block/uuid block-uuid]))))
+              (update-title! conn-a block-uuid "test")
+              (is (seq (#'sync-apply/pending-txs repo-a)))
+              (d/transact! conn-b [[:db/add [:block/uuid block-uuid] :block/updated-at 1710000000000]])
+              (sync-loop! server [{:repo repo-b :conn conn-b :client client-b :online? true}])
+              (sync-loop! server [{:repo repo-a :conn conn-a :client client-a :online? true}])
+              (is (= "test" (:block/title (d/entity @conn-a [:block/uuid block-uuid]))))
+              (is (= (sync-checksum/recompute-checksum @conn-a)
+                     (client-op/get-local-checksum repo-a)))
+              (is (= (sync-checksum/recompute-checksum @conn-b)
+                     (client-op/get-local-checksum repo-b)))
+              (finally
+                (d/unlisten! conn-a listener-a)
+                (d/unlisten! conn-b listener-b)))))))))
 
 (deftest undo-redo-indent-sequence-does-not-produce-invalid-entity-test
   (testing "undo/redo of add-1 add-2 indent-2 should remain valid after another undo"
@@ -1973,6 +2013,77 @@
         steps
         (recur (inc steps))))))
 
+(deftest two-clients-offline-insert-delete-indent-undo-redo-keeps-checksum-cache-aligned-test
+  (testing "both clients offline insert/delete/indent/outdent + undo-all/redo-all keep cached checksums aligned after reconnect"
+    (let [seed (or (env-seed) default-seed)
+          rng (make-rng seed)
+          gen-uuid #(rng-uuid rng)
+          base-uuid (gen-uuid)
+          conn-a (db-test/create-conn)
+          conn-b (db-test/create-conn)
+          ops-a (d/create-conn client-op/schema-in-db)
+          ops-b (d/create-conn client-op/schema-in-db)
+          client-a (make-client repo-a)
+          client-b (make-client repo-b)
+          server (make-server)]
+      (with-test-repos {repo-a {:conn conn-a :ops-conn ops-a}
+                        repo-b {:conn conn-b :ops-conn ops-b}}
+        (fn []
+          (let [listener-a ::checksum-sync-a
+                listener-b ::checksum-sync-b
+                update-local-checksum!
+                (fn [repo conn listener-key]
+                  (d/listen! conn listener-key
+                             (fn [tx-report]
+                               (when-not (:batch-tx? @conn)
+                                 (when (seq (:tx-data tx-report))
+                                   (db-sync/update-local-sync-checksum! repo tx-report)))))
+                  nil)
+                run-offline-seq!
+                (fn [repo conn label-prefix]
+                  (let [base (d/entity @conn [:block/uuid base-uuid])
+                        p1 (gen-uuid)
+                        child (gen-uuid)
+                        temp (gen-uuid)]
+                    (create-block! conn base (str label-prefix "-p1") p1)
+                    (create-block! conn (d/entity @conn [:block/uuid p1]) (str label-prefix "-child") child)
+                    (outliner-core/indent-outdent-blocks! conn [(d/entity @conn [:block/uuid child])] false {})
+                    (outliner-core/indent-outdent-blocks! conn [(d/entity @conn [:block/uuid child])] true {})
+                    (create-block! conn base (str label-prefix "-temp") temp)
+                    (delete-block! conn temp)
+                    (undo-all! repo 256)
+                    (redo-all! repo 256)))]
+            (update-local-checksum! repo-a conn-a listener-a)
+            (update-local-checksum! repo-b conn-b listener-b)
+            (try
+              (reset! db-sync/*repo->latest-remote-tx {})
+              (doseq [repo [repo-a repo-b]]
+                (client-op/update-local-tx repo 0))
+              (ensure-base-page! conn-a base-uuid)
+              (sync-loop! server [{:repo repo-a :conn conn-a :client client-a :online? true}
+                                  {:repo repo-b :conn conn-b :client client-b :online? true}])
+              (client-op/update-local-checksum repo-a (sync-checksum/recompute-checksum @conn-a))
+              (client-op/update-local-checksum repo-b (sync-checksum/recompute-checksum @conn-b))
+
+              (run-offline-seq! repo-a conn-a "a")
+              (run-offline-seq! repo-b conn-b "b")
+
+              (let [rounds (sync-until-idle! server [{:repo repo-a :conn conn-a :client client-a :online? true}
+                                                     {:repo repo-b :conn conn-b :client client-b :online? true}]
+                                            300)]
+                (is (< rounds 300)))
+
+              (let [checksum-a (sync-checksum/recompute-checksum @conn-a)
+                    checksum-b (sync-checksum/recompute-checksum @conn-b)
+                    cached-a (client-op/get-local-checksum repo-a)
+                    cached-b (client-op/get-local-checksum repo-b)]
+                (is (= checksum-a checksum-b))
+                (is (= checksum-a cached-a))
+                (is (= checksum-b cached-b)))
+              (finally
+                (d/unlisten! conn-a listener-a)
+                (d/unlisten! conn-b listener-b)))))))))
+
 (deftest ^:long ^:large-vars/cleanup-todo all-core-outliner-ops-local-undo-redo-random-sim-test
   (testing "local randomized stress simulation runs weighted ops and keeps undo-all/redo-all roundtrips valid"
     (let [seed (or (env-seed) default-seed)

+ 66 - 0
src/test/frontend/worker/db_sync_test.cljs

@@ -19,6 +19,7 @@
             [frontend.worker.sync.presence :as sync-presence]
             [frontend.worker.sync.temp-sqlite :as sync-temp-sqlite]
             [frontend.worker.sync.upload :as sync-upload]
+            [frontend.worker.undo-redo :as undo-redo]
             [logseq.common.config :as common-config]
             [logseq.common.util :as common-util]
             [logseq.common.util.page-ref :as page-ref]
@@ -2391,6 +2392,71 @@
             (is (empty? (#'sync-apply/pending-txs test-repo)))
             (is (= 1 (client-op/get-local-tx test-repo)))))))))
 
+(deftest tx-batch-ok-real-checksum-mismatch-fails-fast-test
+  (testing "tx/batch/ok fails fast on true checksum mismatch"
+    (let [{:keys [conn client-ops-conn]} (setup-parent-child)
+          stale-checksum "0000000000000000"
+          remote-checksum "ffffffffffffffff"
+          client {:repo test-repo
+                  :graph-id "graph-1"
+                  :inflight (atom [])
+                  :online-users (atom [])
+                  :ws-state (atom :open)}
+          raw-message (js/JSON.stringify (clj->js {:type "tx/batch/ok"
+                                                   :t 0
+                                                   :checksum remote-checksum}))]
+      (with-datascript-conns conn client-ops-conn
+        (fn []
+          (client-op/update-local-checksum test-repo stale-checksum)
+          (try
+            (sync-handle-message/handle-message! test-repo client raw-message)
+            (is false "expected checksum mismatch to fail fast")
+            (catch :default error
+              (let [data (ex-data error)]
+                (is (= :db-sync/checksum-mismatch (:type data)))
+                (is (= stale-checksum (:local-checksum data)))
+                (is (= remote-checksum (:remote-checksum data)))))))))))
+
+(deftest local-checksum-stays-in-sync-after-undo-redo-sequence-test
+  (testing "insert/delete/indent/outdent with undo-all/redo-all keeps cached checksum aligned"
+    (let [{:keys [conn client-ops-conn parent]} (setup-parent-child)
+          inserted-uuid (random-uuid)]
+      (with-datascript-conns conn client-ops-conn
+        (fn []
+          (client-op/update-local-checksum test-repo (sync-checksum/recompute-checksum @conn))
+          (d/listen! conn ::checksum-sync
+                     (fn [tx-report]
+                       (when-not (:batch-tx? @conn)
+                         (when (seq (:tx-data tx-report))
+                           (db-sync/update-local-sync-checksum! test-repo tx-report)))))
+          (try
+            (outliner-core/insert-blocks! conn
+                                          [{:block/uuid inserted-uuid
+                                            :block/title "tmp"}]
+                                          parent
+                                          {:sibling? false
+                                           :keep-uuid? true})
+            (let [inserted (d/entity @conn [:block/uuid inserted-uuid])]
+              (outliner-core/indent-outdent-blocks! conn [inserted] true)
+              (outliner-core/indent-outdent-blocks! conn [inserted] false)
+              (outliner-core/delete-blocks @conn [inserted] {}))
+            (loop [n 0]
+              (let [result (undo-redo/undo test-repo)]
+                (when-not (= :frontend.worker.undo-redo/empty-undo-stack result)
+                  (when (> n 128)
+                    (throw (ex-info "undo loop exceeded" {:count n})))
+                  (recur (inc n)))))
+            (loop [n 0]
+              (let [result (undo-redo/redo test-repo)]
+                (when-not (= :frontend.worker.undo-redo/empty-redo-stack result)
+                  (when (> n 128)
+                    (throw (ex-info "redo loop exceeded" {:count n})))
+                  (recur (inc n)))))
+            (is (= (sync-checksum/recompute-checksum @conn)
+                   (client-op/get-local-checksum test-repo)))
+            (finally
+              (d/unlisten! conn ::checksum-sync))))))))
+
 (deftest reparent-block-when-cycle-detected-test
   (testing "cycle from remote sync reparent block to page root"
     (let [{:keys [conn parent child1]} (setup-parent-child)]