test(sync): cover stale fix/reject flows

This commit is contained in:
Tienson Qin
2026-04-09 22:43:52 +08:00
parent 7dbe9e5c9f
commit 256fa25ef5
4 changed files with 566 additions and 28 deletions

View File

@@ -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

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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)