From 256fa25ef54ef0743debcb4bb5d3347930625049 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 9 Apr 2026 22:43:52 +0800 Subject: [PATCH] test(sync): cover stale fix/reject flows --- .../logseq/db_sync/worker/handler/sync.cljs | 8 +- .../db_sync/worker_handler_sync_test.cljs | 48 +++ .../frontend/worker/db_sync_sim_test.cljs | 226 +++++++++++++ src/test/frontend/worker/db_sync_test.cljs | 312 ++++++++++++++++-- 4 files changed, 566 insertions(+), 28 deletions(-) diff --git a/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs b/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs index 1698e19e2c..7f0c9293a5 100644 --- a/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs +++ b/deps/db-sync/src/logseq/db_sync/worker/handler/sync.cljs @@ -311,10 +311,10 @@ outliner-op (assoc :outliner-op outliner-op))) true (catch :default e - ;; Rebase txs are inferred from local history and can become stale when - ;; concurrent remote edits remove referenced entities before upload. - ;; Treat stale :entity-id/missing rebases as no-op so sync can continue. - (if (and (= outliner-op :rebase) + ;; Rebase/fix txs are inferred from local history and can become stale + ;; when concurrent remote edits remove referenced entities before upload. + ;; Treat stale :entity-id/missing rebases/fixes as no-op so sync can continue. + (if (and (contains? #{:rebase :fix} outliner-op) (= :entity-id/missing (:error (ex-data e)))) (do (log/warn :db-sync/drop-stale-rebase-tx diff --git a/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs b/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs index eba5389219..ad57dcb221 100644 --- a/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs +++ b/deps/db-sync/test/logseq/db_sync/worker_handler_sync_test.cljs @@ -760,6 +760,54 @@ (is (empty? (storage/fetch-tx-since sql t-before))) (is (empty? @changed-messages))))) +(deftest tx-batch-ignores-stale-fix-with-missing-lookup-entity-test + (testing "stale fix lookup refs to missing entities are treated as no-op" + (let [sql (test-sql/make-sql) + conn (storage/open-conn sql) + self #js {:sql sql + :conn conn + :schema-ready true} + page-uuid (random-uuid) + sibling-uuid (random-uuid) + missing-block-uuid (random-uuid) + _ (d/transact! conn [{:block/uuid page-uuid + :block/name "fix-stale-page" + :block/title "fix-stale-page"} + {:block/uuid sibling-uuid + :block/title "existing-sibling" + :block/order "a5Uzl" + :block/parent [:block/uuid page-uuid] + :block/page [:block/uuid page-uuid]}]) + t-before (storage/get-t sql) + checksum-before (storage/get-checksum sql) + tx-entry {:tx (protocol/tx->transit + [[:db/retract [:block/uuid missing-block-uuid] + :block/order + "a5Uzl" + 536871101] + [:db/add [:block/uuid missing-block-uuid] + :block/order + "a5c" + 536871101] + [:db/retract [:block/uuid sibling-uuid] + :block/order + "a5Uzl" + 536871101] + [:db/add [:block/uuid sibling-uuid] + :block/order + "a5k" + 536871101]]) + :outliner-op :fix} + changed-messages (atom []) + response (with-redefs [ws/broadcast! (fn [_self _sender payload] + (swap! changed-messages conj payload))] + (sync-handler/handle-tx-batch! self nil [tx-entry] t-before))] + (is (= "tx/batch/ok" (:type response))) + (is (= t-before (:t response))) + (is (= checksum-before (storage/get-checksum sql))) + (is (empty? (storage/fetch-tx-since sql t-before))) + (is (empty? @changed-messages))))) + (deftest server-incremental-checksum-matches-full-recompute-fuzz-test (testing "server stored checksum stays equal to full recompute across randomized tx/rebase/no-op sequences" (doseq [seed (range 1 11)] diff --git a/src/test/frontend/worker/db_sync_sim_test.cljs b/src/test/frontend/worker/db_sync_sim_test.cljs index 188e816513..aea4be500e 100644 --- a/src/test/frontend/worker/db_sync_sim_test.cljs +++ b/src/test/frontend/worker/db_sync_sim_test.cljs @@ -1102,6 +1102,73 @@ (:block/uuid (d/entity db id)))) set)) +(defn- block-tree-preorder + [root] + (letfn [(walk [node] + (cons node + (mapcat walk (ldb/sort-by-order (:block/_parent node)))))] + (walk root))) + +(defn- random-copied-block-tree + [rng source] + (let [nodes (vec (block-tree-preorder source)) + max-size (max 1 (min 12 (count nodes))) + size (+ 1 (rand-int! rng max-size)) + nodes' (subvec nodes 0 size) + uuid-map (into {} + (map (fn [node] + [(:block/uuid node) (rng-uuid rng)]) + nodes')) + copied (mapv (fn [node] + (let [old-uuid (:block/uuid node) + new-uuid (get uuid-map old-uuid) + parent-uuid (some-> node :block/parent :block/uuid)] + (cond-> {:block/uuid new-uuid + :block/title (or (:block/title node) "")} + (contains? uuid-map parent-uuid) + (assoc :block/parent [:block/uuid (get uuid-map parent-uuid)])))) + nodes')] + copied)) + +(defn- op-copy-paste-block-tree-into-empty-target! + [rng conn state _base-uuid] + (let [db @conn + sources (->> (existing-blocks db (:blocks @state)) + (filter (fn [block] + (seq (ldb/sort-by-order (:block/_parent block)))))) + source (rand-nth! rng (vec sources))] + (when source + (let [source-uuid (:block/uuid source) + source-page-uuid (:block/uuid (:block/page source)) + source-descendants (block-and-descendant-uuids db source) + targets (->> (existing-blocks db (:blocks @state)) + (remove (fn [target] + (contains? source-descendants (:block/uuid target)))) + (filter (fn [target] + (and (= source-page-uuid + (:block/uuid (:block/page target))) + (string/blank? (or (:block/title target) "")) + (empty? (:block/_parent target)))))) + target (rand-nth! rng (vec targets))] + (when target + (let [target-uuid (:block/uuid target) + copied-tree (random-copied-block-tree rng source)] + (when (seq copied-tree) + ;; Simulate "copy + paste tree into empty target block" using + ;; replace-empty-target paste semantics. + (outliner-op/apply-ops! + conn + [[:insert-blocks [copied-tree + (:db/id target) + {:sibling? true + :outliner-op :paste + :replace-empty-target? true}]]] + {}) + {:op :copy-paste-block-tree-into-empty-target + :uuid source-uuid + :target target-uuid + :copied-size (count copied-tree)}))))))) + (defn- op-cut-paste-block-with-child! [rng conn state _base-uuid] (let [db @conn sources (->> (existing-blocks db (:blocks @state)) @@ -1180,10 +1247,16 @@ {:name :redo :weight 10 :f op-redo!} {:name :create-block :weight 10 :f op-create-block!} {:name :move-block :weight 6 :f op-move-block!} + {:name :copy-paste-block-tree-into-empty-target :weight 4 :f op-copy-paste-block-tree-into-empty-target!} {:name :cut-paste-block-with-child :weight 4 :f op-cut-paste-block-with-child!} {:name :delete-block :weight 4 :f op-delete-block!} {:name :update-title :weight 8 :f op-update-title!}]) +(deftest copy-paste-tree-op-registered-in-sim-op-table-test + (testing "sim op-table includes copy-paste tree op for random sync stress" + (is (contains? (set (map :name op-table)) + :copy-paste-block-tree-into-empty-target)))) + (deftest cut-paste-op-registered-in-sim-op-table-test (testing "sim op-table includes cut-paste op for random sync stress" (is (contains? (set (map :name op-table)) @@ -1450,6 +1523,7 @@ :create-block (f rng conn state base-uuid {:gen-uuid gen-uuid}) :update-title (f rng conn state base-uuid) :move-block (f rng conn state base-uuid) + :copy-paste-block-tree-into-empty-target (f rng conn state base-uuid) :cut-paste-block-with-child (f rng conn state base-uuid) :delete-block (f rng conn state) (f rng conn))] @@ -2674,6 +2748,158 @@ (finally (restore))))))))) +(deftest ^:long ^:large-vars/cleanup-todo two-clients-a-wins-b-overlap-rebase-3-tries-test + (testing "three deterministic tries: B rebases pending deletes while applying overlapping remote slices" + (doseq [seed [301 302 303]] + (let [rng (make-rng seed) + gen-uuid #(rng-uuid rng) + scenario-runs 90 + 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) + history (atom []) + state-a (atom {:pages #{base-uuid} :blocks #{}}) + state-b (atom {:pages #{base-uuid} :blocks #{}}) + a-ops #{:create-block + :delete-block + :move-block + :indent-outdent-blocks + :copy-paste-block-tree-into-empty-target + :undo + :redo} + a-op-weights {:create-block 18 + :delete-block 10 + :move-block 10 + :indent-outdent-blocks 10 + :copy-paste-block-tree-into-empty-target 8 + :undo 8 + :redo 8} + b-ops #{:delete-block :delete-blocks} + b-op-weights {:delete-block 14 + :delete-blocks 14} + a-op-table (build-weighted-op-table a-ops a-op-weights :a-overlap-rebase) + b-op-table (build-weighted-op-table b-ops b-op-weights :b-overlap-rebase) + overlap-apply-count (atom 0) + b-rebase-with-pending (atom 0)] + (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) + refresh-state! (fn [state conn] + (let [db @conn + block-uuids (->> (active-block-uuids db) + (remove (fn [uuid] + (some-> (d/entity db [:block/uuid uuid]) + ldb/page?))) + set)] + (swap! state assoc :pages #{base-uuid} + :blocks block-uuids))) + sync-a-then-overlap-b! + (fn [iter-idx] + (sync-client! server {:repo repo-a + :conn conn-a + :client client-a + :online? true + :gen-uuid gen-uuid}) + (let [local-tx-b (or (client-op/get-local-tx repo-b) 0) + server-t (:t @server)] + (when (< local-tx-b server-t) + (let [pending-before (boolean (seq (#'sync-apply/pending-txs repo-b))) + remote-txs (mapv (fn [tx-data] {:tx-data tx-data}) + (server-pull server local-tx-b)) + slices (if (<= (count remote-txs) 1) + [remote-txs] + (let [split (+ 1 (rand-int! rng (dec (count remote-txs)))) + left (subvec remote-txs 0 split) + ;; Intentional overlap to mimic duplicated/out-of-order pulls. + right (subvec remote-txs (max 0 (dec split)))] + [left right]))] + (when pending-before + (swap! b-rebase-with-pending inc)) + (doseq [slice slices] + (when (seq slice) + (try + (#'sync-apply/apply-remote-txs! repo-b client-b slice) + (catch :default e + (report-history! seed history + {:type :b-overlap-apply-remote-failed + :iter iter-idx + :slice-size (count slice) + :local-tx local-tx-b + :server-t server-t + :pending-before pending-before + :error (ex-data e)}) + (throw e))))) + (when (> (count slices) 1) + (swap! overlap-apply-count inc)) + (client-op/update-local-tx repo-b server-t)))) + (refresh-state! state-a conn-a) + (refresh-state! state-b conn-b))] + (try + (reset! db-sync/*repo->latest-remote-tx {}) + (record-meta! history {:seed seed + :base-uuid base-uuid + :phase :a-wins-b-overlap-rebase + :scenario-runs scenario-runs}) + (doseq [conn [conn-a conn-b]] + (ensure-base-page! conn base-uuid)) + (doseq [repo [repo-a repo-b]] + (client-op/update-local-tx repo 0)) + + (let [base-a (d/entity @conn-a [:block/uuid base-uuid])] + (dotimes [i 10] + (let [seed-block-uuid (gen-uuid)] + (create-block! conn-a base-a (str "seed-overlap-" i) seed-block-uuid) + (swap! state-a update :blocks conj seed-block-uuid)))) + + (sync-a-then-overlap-b! -1) + + (dotimes [i scenario-runs] + (run-ops! rng {:repo repo-a + :conn conn-a + :base-uuid base-uuid + :state state-a + :gen-uuid gen-uuid} + 1 + history + {:op-table-override a-op-table + :context {:phase :a-overlap-op :iter i}}) + (run-ops! rng {:repo repo-b + :conn conn-b + :base-uuid base-uuid + :state state-b + :gen-uuid gen-uuid} + 1 + history + {:op-table-override b-op-table + :context {:phase :b-delete-op :iter i}}) + (sync-a-then-overlap-b! i)) + + (sync-a-then-overlap-b! scenario-runs) + + (let [issues-a (db-issues @conn-a) + issues-b (db-issues @conn-b) + checksum-a (sync-checksum/recompute-checksum @conn-a) + checksum-server (sync-checksum/recompute-checksum @(get @server :conn))] + (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))) + (is (= checksum-a checksum-server) + (str "winner/server checksum mismatch seed=" seed + " a=" checksum-a + " server=" checksum-server)) + (is (pos? @b-rebase-with-pending) + (str "expected rebases with pending deletes seed=" seed)) + (is (pos? @overlap-apply-count) + (str "expected overlapping apply-remote slices seed=" seed)) + (assert-no-invalid-tx! seed history repro)) + (finally + (restore)))))))))) + (deftest ^:long ^:large-vars/cleanup-todo three-clients-single-repo-sim-test (testing "db-sync convergence with three clients sharing one repo" (let [seed (or (env-seed) default-seed) diff --git a/src/test/frontend/worker/db_sync_test.cljs b/src/test/frontend/worker/db_sync_test.cljs index 6d0f855d2f..c180ac2967 100644 --- a/src/test/frontend/worker/db_sync_test.cljs +++ b/src/test/frontend/worker/db_sync_test.cljs @@ -347,6 +347,64 @@ (is (= tx-id payload-tx-id)) (is (string? payload-tx-id))))) +(deftest coerce-ws-server-message-accepts-legacy-tx-reject-shape-test + (testing "legacy tx/reject with error-detail and UUID-object ids should coerce" + (let [failed-tx-id (random-uuid) + success-tx-id (random-uuid) + coerced (sync-transport/coerce-ws-server-message + {:type "tx/reject" + :reason "db transact failed" + :t 1392 + :error-detail "legacy server detail" + :failed-tx-id {:uuid (str failed-tx-id)} + :success-tx-ids [{:uuid (str success-tx-id)}]})] + (is (= "tx/reject" (:type coerced))) + (is (= "legacy server detail" (:error-detail coerced))) + (is (= failed-tx-id (:failed-tx-id coerced))) + (is (= [success-tx-id] (:success-tx-ids coerced)))))) + +(deftest flush-pending-honors-stop-upload-debug-flag-test + (testing "when stop-upload debug flag is enabled, flush-pending should skip preparing/sending tx batches" + (let [{:keys [conn client-ops-conn child1]} (setup-parent-child) + tx-id (random-uuid) + prepare-calls (atom 0) + send-calls (atom 0) + client {:repo test-repo + :graph-id "graph-1" + :inflight (atom []) + :ws (doto (js-obj) + (aset "readyState" 1) + (aset "send" (fn [_raw] (swap! send-calls inc))))}] + (with-datascript-conns conn client-ops-conn + (fn [] + (reset! sync-apply/*repo->latest-remote-tx {test-repo 0}) + (client-op/update-local-tx test-repo 0) + (ldb/transact! client-ops-conn + [{:db-sync/tx-id tx-id + :db-sync/pending? true + :db-sync/created-at 1 + :db-sync/outliner-op :save-block + :db-sync/normalized-tx-data + [[:db/add [:block/uuid (:block/uuid child1)] + :block/title + "pending upload debug gate test"]]}]) + (with-redefs [worker-state/online? (constantly true) + sync-apply/prepare-upload-tx-entries + (fn [_conn _pending] + (swap! prepare-calls inc) + {:tx-entries [] + :drop-tx-ids []})] + (#'sync-apply/set-upload-stopped! test-repo true) + (#'sync-apply/flush-pending! test-repo client) + (is (= 0 @prepare-calls)) + (is (= 0 @send-calls)) + + (#'sync-apply/set-upload-stopped! test-repo false) + (#'sync-apply/flush-pending! test-repo client) + (is (= 1 @prepare-calls)) + (is (= 0 @send-calls))) + (#'sync-apply/set-upload-stopped! test-repo false)))))) + (deftest sync-counts-counts-only-true-pending-local-ops-test (testing "pending-local should count only rows with :db-sync/pending? true" (let [{:keys [conn client-ops-conn]} (setup-parent-child)] @@ -571,35 +629,117 @@ (deftest tx-reject-stale-keeps-inflight-op-pending-test (testing "stale tx/reject should keep inflight ops pending for retry" - (let [{:keys [conn client-ops-conn]} (setup-parent-child) - tx-id (random-uuid) - *sent (atom []) - raw-message (js/JSON.stringify - (clj->js {:type "tx/reject" - :reason "stale" - :t 3})) + (async done + (let [{:keys [conn client-ops-conn]} (setup-parent-child) + tx-id (random-uuid) + *sent (atom []) + ws (doto (js-obj) + (aset "readyState" 1) + (aset "send" (fn [raw] + (swap! *sent conj (js->clj (js/JSON.parse raw) :keywordize-keys true))))) + raw-message (js/JSON.stringify + (clj->js {:type "tx/reject" + :reason "stale" + :t 3})) + client {:repo test-repo + :graph-id "graph-1" + :ws ws + :send-queue (atom (p/resolved nil)) + :inflight (atom [tx-id]) + :online-users (atom []) + :ws-state (atom :open)}] + (with-datascript-conns conn client-ops-conn + (fn [] + (ldb/transact! client-ops-conn + [{:db-sync/tx-id tx-id + :db-sync/created-at 1 + :db-sync/pending? true}]) + (with-redefs [client-op/get-local-tx (constantly 0)] + (sync-handle-message/handle-message! test-repo client raw-message) + (-> @(:send-queue client) + (p/then (fn [_] + (let [ent (d/entity @client-ops-conn [:db-sync/tx-id tx-id])] + (is (= [{:type "pull" :since 0}] @*sent)) + (is (= [tx-id] @(:inflight client))) + (is (= true (:db-sync/pending? ent))) + (is (not= true (:db-sync/failed? ent)))))) + (p/finally (fn [] (done))))))))))) + +(deftest tx-reject-stale-dedupes-pull-request-test + (testing "repeated stale tx/reject should not send duplicated pull requests" + (async done + (let [*sent (atom []) + ws (doto (js-obj) + (aset "readyState" 1) + (aset "send" (fn [raw] + (swap! *sent conj (js->clj (js/JSON.parse raw) :keywordize-keys true))))) + raw-message (js/JSON.stringify + (clj->js {:type "tx/reject" + :reason "stale" + :t 3})) + client {:repo test-repo + :graph-id "graph-1" + :ws ws + :send-queue (atom (p/resolved nil)) + :pending-pull-since (atom nil) + :inflight (atom []) + :online-users (atom []) + :ws-state (atom :open)}] + (with-redefs [client-op/get-local-tx (constantly 0)] + (sync-handle-message/handle-message! test-repo client raw-message) + (sync-handle-message/handle-message! test-repo client raw-message) + (-> @(:send-queue client) + (p/then (fn [_] + (is (= [{:type "pull" :since 0}] @*sent)) + (is (= 0 @(:pending-pull-since client))))) + (p/finally (fn [] (done))))))))) + +(deftest changed-message-dedupes-pull-request-test + (testing "repeated changed should not send duplicated pull requests" + (async done + (let [*sent (atom []) + ws (doto (js-obj) + (aset "readyState" 1) + (aset "send" (fn [raw] + (swap! *sent conj (js->clj (js/JSON.parse raw) :keywordize-keys true))))) + raw-message (js/JSON.stringify + (clj->js {:type "changed" + :t 10})) + client {:repo test-repo + :graph-id "graph-1" + :ws ws + :send-queue (atom (p/resolved nil)) + :pending-pull-since (atom nil) + :inflight (atom []) + :online-users (atom []) + :ws-state (atom :open)}] + (with-redefs [client-op/get-local-tx (constantly 3)] + (sync-handle-message/handle-message! test-repo client raw-message) + (sync-handle-message/handle-message! test-repo client raw-message) + (-> @(:send-queue client) + (p/then (fn [_] + (is (= [{:type "pull" :since 3}] @*sent)) + (is (= 3 @(:pending-pull-since client))))) + (p/finally (fn [] (done))))))))) + +(deftest pull-ok-clears-pending-pull-request-marker-test + (testing "pull/ok clears pending pull marker so future changed can request next pull" + (let [raw-message (js/JSON.stringify + (clj->js {:type "pull/ok" + :t 4 + :txs []})) client {:repo test-repo :graph-id "graph-1" :ws #js {} - :inflight (atom [tx-id]) + :pending-pull-since (atom 3) + :inflight (atom []) :online-users (atom []) :ws-state (atom :open)}] - (with-datascript-conns conn client-ops-conn - (fn [] - (ldb/transact! client-ops-conn - [{:db-sync/tx-id tx-id - :db-sync/created-at 1 - :db-sync/pending? true}]) - (with-redefs [client-op/get-local-tx (constantly 0) - sync-transport/ws-open? (constantly true) - sync-transport/send! (fn [_coerce-f _ws message] - (swap! *sent conj message))] - (sync-handle-message/handle-message! test-repo client raw-message) - (let [ent (d/entity @client-ops-conn [:db-sync/tx-id tx-id])] - (is (= [{:type "pull" :since 0}] @*sent)) - (is (= [tx-id] @(:inflight client))) - (is (= true (:db-sync/pending? ent))) - (is (not= true (:db-sync/failed? ent)))))))))) + (with-redefs [client-op/get-local-tx (constantly 3) + client-op/update-local-tx (fn [_repo _t] nil) + sync-apply/flush-pending! (fn [& _] nil)] + (sync-handle-message/handle-message! test-repo client raw-message) + (is (nil? @(:pending-pull-since client))))))) (deftest hello-checksum-mismatch-logs-warning-test (testing "hello with matching t but mismatched checksum logs warning without throwing" @@ -3767,6 +3907,130 @@ (when target' (is (= "remote-restored" (:block/title target')))))))))) +(deftest apply-remote-txs-local-delete-parent-remote-move-then-delete-parent-repro-test + (testing "reproduces transact-remote failure when remote moves blocks under a locally deleted parent and then retracts that parent" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "page 1"} + :blocks [{:block/title "parent"} + {:block/title "mover-1"} + {:block/title "mover-2"}]}]}) + client-ops-conn (d/create-conn client-op/schema-in-db) + parent (db-test/find-block-by-content @conn "parent") + mover-1 (db-test/find-block-by-content @conn "mover-1") + mover-2 (db-test/find-block-by-content @conn "mover-2") + parent-uuid (:block/uuid parent) + page-uuid (:block/uuid (:block/page parent)) + mover-1-uuid (:block/uuid mover-1) + mover-2-uuid (:block/uuid mover-2) + mover-1-order (:block/order mover-1) + mover-2-order (:block/order mover-2) + remote-txs [{:tx-data [[:db/retract [:block/uuid mover-1-uuid] :block/parent [:block/uuid page-uuid]] + [:db/add [:block/uuid mover-1-uuid] :block/parent [:block/uuid parent-uuid]] + [:db/retract [:block/uuid mover-1-uuid] :block/order mover-1-order] + [:db/add [:block/uuid mover-1-uuid] :block/order "ZxV"]]} + {:tx-data [[:db/retract [:block/uuid mover-2-uuid] :block/parent [:block/uuid page-uuid]] + [:db/add [:block/uuid mover-2-uuid] :block/parent [:block/uuid parent-uuid]] + [:db/retract [:block/uuid mover-2-uuid] :block/order mover-2-order] + [:db/add [:block/uuid mover-2-uuid] :block/order "ZxG"]]} + {:tx-data [[:db/retractEntity [:block/uuid parent-uuid]]]}] + client {:repo test-repo + :graph-id "graph-1" + :inflight (atom []) + :online-users (atom []) + :ws-state (atom :open)}] + (with-datascript-conns conn client-ops-conn + (fn [] + ;; Local delete creates pending tx requiring reverse before remote apply. + (outliner-core/delete-blocks! conn [parent] {}) + (is (seq (#'sync-apply/pending-txs test-repo))) + (let [result (try + (#'sync-apply/apply-remote-txs! test-repo client remote-txs) + nil + (catch :default e + e))] + (is (instance? js/Error result)) + (is (string/includes? (or (ex-message result) "") + "DB write failed with invalid data") + (str "unexpected error: " (ex-message result))))))))) + +(deftest apply-remote-txs-overlap-out-of-order-parent-delete-then-move-repro-test + (testing "reproduces missing-parent transact-remote failure when overlapping remote slices arrive out of order" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "page 1"} + :blocks [{:block/title "parent"} + {:block/title "mover"} + {:block/title "local-pending-delete"}]}]}) + client-ops-conn (d/create-conn client-op/schema-in-db) + parent (db-test/find-block-by-content @conn "parent") + mover (db-test/find-block-by-content @conn "mover") + local-delete (db-test/find-block-by-content @conn "local-pending-delete") + page-uuid (:block/uuid (:block/page parent)) + parent-uuid (:block/uuid parent) + mover-uuid (:block/uuid mover) + mover-order (:block/order mover) + tx-delete-parent {:tx-data [[:db/retractEntity [:block/uuid parent-uuid]]]} + tx-move-under-parent + {:tx-data [[:db/retract [:block/uuid mover-uuid] :block/parent [:block/uuid page-uuid]] + [:db/add [:block/uuid mover-uuid] :block/parent [:block/uuid parent-uuid]] + [:db/retract [:block/uuid mover-uuid] :block/order mover-order] + [:db/add [:block/uuid mover-uuid] :block/order "ZxV"]]} + client {:repo test-repo + :graph-id "graph-1" + :inflight (atom []) + :online-users (atom []) + :ws-state (atom :open)}] + (with-datascript-conns conn client-ops-conn + (fn [] + ;; Keep one unrelated local pending tx so apply-remote uses reverse+rebase path. + (outliner-core/delete-blocks! conn [local-delete] {}) + (is (= 1 (count (#'sync-apply/pending-txs test-repo)))) + + ;; Simulate overlapped/out-of-order pull slices: + ;; 1) later tx deletes parent + ;; 2) earlier tx moves a block under that parent + (#'sync-apply/apply-remote-txs! test-repo client [tx-delete-parent]) + (let [result (try + (#'sync-apply/apply-remote-txs! test-repo client [tx-move-under-parent]) + nil + (catch :default e + e))] + (is (instance? js/Error result)) + (is (string/includes? (or (ex-message result) "") + "Nothing found for entity id") + (str "unexpected error: " (ex-message result))))))))) + +(deftest insert-blocks-reproduces-fractional-index-order-boundary-error-test + (testing "insert-blocks can reproduce fractional-index boundary crash when start-order >= end-order" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "page 1"} + :blocks [{:block/title "target"} + {:block/title "right-sibling"}]}]}) + target (db-test/find-block-by-content @conn "target") + right-sibling (db-test/find-block-by-content @conn "right-sibling")] + ;; Force a malformed sibling order interval that matches production symptom: + ;; start "a4wv" and end "a4wt" (start >= end). + (d/transact! conn + [[:db/add (:db/id target) :block/order "a4wv"] + [:db/add (:db/id right-sibling) :block/order "a4wt"]]) + (let [result (try + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/title "insert crash repro"}] + (:db/id target) + {:sibling? true + :right-sibling-id (:db/id right-sibling)}]]] + {}) + nil + (catch :default e + e))] + (is (instance? js/Error result)) + (is (string/includes? (or (ex-message result) "") + "a4wv >= a4wt") + (str "unexpected error: " (ex-message result))))))) + (deftest rebase-persisted-row-contains-forward-and-inverse-outliner-ops-test (testing "rebased pending tx should always persist both forward and inverse outliner ops" (let [{:keys [conn client-ops-conn parent child1]} (setup-parent-child)