Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
377 lines
18 KiB
Clojure
377 lines
18 KiB
Clojure
(ns logseq.db-test
|
|
(:require [cljs.test :refer [deftest is testing]]
|
|
[datascript.core :as d]
|
|
[datascript.storage :as ds-storage :refer [IStorage]]
|
|
[logseq.db :as ldb]
|
|
[logseq.db.frontend.validate :as db-validate]
|
|
[logseq.db.test.helper :as db-test]))
|
|
|
|
(defrecord InMemoryStorage [*disk]
|
|
IStorage
|
|
(-store [_ addr+data-seq _delete-addrs]
|
|
(doseq [[addr data] addr+data-seq]
|
|
(vswap! *disk assoc addr data)))
|
|
(-restore [_ addr]
|
|
(get @*disk addr)))
|
|
|
|
(defn- make-storage []
|
|
(->InMemoryStorage (volatile! {})))
|
|
|
|
;;; datoms
|
|
;;; - 1 <----+
|
|
;;; - 2 |
|
|
;;; - 3 -+
|
|
(comment
|
|
;; TODO: throw error or fix broken data when cycle detected
|
|
(def broken-outliner-data-with-cycle
|
|
[{:db/id 1
|
|
:block/uuid #uuid"e538d319-48d4-4a6d-ae70-c03bb55b6fe4"
|
|
:block/parent 3}
|
|
{:db/id 2
|
|
:block/uuid #uuid"c46664c0-ea45-4998-adf0-4c36486bb2e5"
|
|
:block/parent 1}
|
|
{:db/id 3
|
|
:block/uuid #uuid"2b736ac4-fd49-4e04-b00f-48997d2c61a2"
|
|
:block/parent 2}]))
|
|
|
|
(def class-parents-data
|
|
[{:block/tags :logseq.class/Tag
|
|
:block/title "x"
|
|
:block/name "x"
|
|
:block/uuid #uuid "6c353967-f79b-4785-b804-a39b81d72461"}
|
|
{:block/tags :logseq.class/Tag
|
|
:block/title "y"
|
|
:block/name "y"
|
|
:block/uuid #uuid "7008db08-ba0c-4aa9-afc6-7e4783e40a99"
|
|
:logseq.property.class/extends [:block/uuid #uuid "6c353967-f79b-4785-b804-a39b81d72461"]}
|
|
{:block/tags :logseq.class/Tag
|
|
:block/title "z"
|
|
:block/name "z"
|
|
:block/uuid #uuid "d95f2912-a7af-41b9-8ed5-28861f7fc0be"
|
|
:logseq.property.class/extends [:block/uuid #uuid "7008db08-ba0c-4aa9-afc6-7e4783e40a99"]}])
|
|
|
|
(deftest get-class-extends
|
|
(let [conn (db-test/create-conn)]
|
|
(d/transact! conn class-parents-data)
|
|
(is (= #{"x" "y"}
|
|
(->> (ldb/get-class-extends (ldb/get-page @conn "z"))
|
|
(map :block/title)
|
|
set)))))
|
|
|
|
(deftest get-case-page
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:properties
|
|
{:foo {:logseq.property/type :default}
|
|
:Foo {:logseq.property/type :default}}
|
|
:classes {:movie {} :Movie {}}})]
|
|
;; Case sensitive properties
|
|
(is (= "foo" (:block/title (ldb/get-case-page @conn "foo"))))
|
|
(is (= "Foo" (:block/title (ldb/get-case-page @conn "Foo"))))
|
|
;; Case sensitive classes
|
|
(is (= "movie" (:block/title (ldb/get-case-page @conn "movie"))))
|
|
(is (= "Movie" (:block/title (ldb/get-case-page @conn "Movie"))))))
|
|
|
|
(deftest get-journal-page-by-day
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks
|
|
[{:page {:build/journal 20260410}}
|
|
{:page {:build/journal 20260411}}]})]
|
|
(is (= "Apr 10th, 2026"
|
|
(:block/title (ldb/get-journal-page-by-day @conn 20260410))))
|
|
(is (= "Apr 11th, 2026"
|
|
(:block/title (ldb/get-journal-page-by-day @conn 20260411))))))
|
|
|
|
(deftest page-exists
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:properties
|
|
{:foo {:logseq.property/type :default}
|
|
:Foo {:logseq.property/type :default}}
|
|
:classes {:movie {} :Movie {}}})]
|
|
(is (= ["foo"]
|
|
(map #(:block/title (d/entity @conn %)) (ldb/page-exists? @conn "foo" #{:logseq.class/Property})))
|
|
"Property pages correctly found for given class")
|
|
(is (= nil
|
|
(ldb/page-exists? @conn "foo" #{:logseq.class/Tag}))
|
|
"Property pages correctly not found for given class")
|
|
(is (= ["movie"]
|
|
(map #(:block/title (d/entity @conn %)) (ldb/page-exists? @conn "movie" #{:logseq.class/Tag})))
|
|
"Class pages correctly found for given class")
|
|
(is (= nil
|
|
(ldb/page-exists? @conn "movie" #{:logseq.class/Property}))
|
|
"Class pages correctly not found for given class")))
|
|
|
|
(deftest test-transact-with-multiple-tx-datoms
|
|
(testing "last write wins with same tx"
|
|
(let [conn (d/create-conn)]
|
|
(d/transact! conn [[:db/add -1 :property :v1]])
|
|
(let [tx (:max-tx @conn)]
|
|
(ldb/transact! conn
|
|
[(d/datom 1 :property :v1 (inc tx) false)
|
|
(d/datom 1 :property :v1 (inc tx) true)]))
|
|
(is (= :v1 (:property (d/entity @conn 1))))))
|
|
(testing "last write wins with different tx"
|
|
(let [conn (d/create-conn)]
|
|
(d/transact! conn [[:db/add -1 :property :v1]])
|
|
(let [tx (:max-tx @conn)]
|
|
(ldb/transact! conn
|
|
[(d/datom 1 :property :v1 (inc tx) false)
|
|
(d/datom 1 :property :v1 (+ tx 2) true)]))
|
|
(is (= :v1 (:property (d/entity @conn 1)))))))
|
|
|
|
(deftest test-batch-transact!
|
|
(testing "DB validation should be running after the whole transaction"
|
|
(let [conn (db-test/create-conn)]
|
|
(testing "#Task shouldn't be converted to property"
|
|
(is (thrown? js/Error
|
|
(with-out-str
|
|
(db-test/silence-stderr
|
|
(ldb/transact! conn [{:db/ident :logseq.class/Task
|
|
:block/tags :logseq.class/Property}]))))))
|
|
(ldb/batch-transact-with-temp-conn!
|
|
conn
|
|
{}
|
|
(fn [temp-conn]
|
|
(ldb/transact! temp-conn [{:db/ident :logseq.class/Task
|
|
:block/tags :logseq.class/Property}])
|
|
(ldb/transact! temp-conn [[:db/retract :logseq.class/Task :block/tags :logseq.class/Property]]))))))
|
|
|
|
(deftest batch-transact-with-temp-conn-preserves-retracts-test
|
|
(testing "temp conn batch replays retractions instead of treating every datom as an add"
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks [{:page {:block/title "page 1"}
|
|
:blocks [{:block/title "old"}]}]})
|
|
block (db-test/find-block-by-content @conn "old")
|
|
block-id (:db/id block)]
|
|
(ldb/batch-transact-with-temp-conn!
|
|
conn
|
|
{}
|
|
(fn [temp-conn]
|
|
(ldb/transact! temp-conn [[:db/retract [:block/uuid (:block/uuid block)] :block/title "old"]])
|
|
(ldb/transact! temp-conn [[:db/add [:block/uuid (:block/uuid block)] :block/title "new"]])))
|
|
(is (= "new" (:block/title (d/entity @conn block-id)))))))
|
|
|
|
(deftest batch-transact-with-temp-conn-preserves-cardinality-one-schema-test
|
|
(testing "temp conn emits replacement retractions for cardinality-one attrs"
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks [{:page {:block/title "page 1"}
|
|
:blocks [{:block/title "old"}]}]})
|
|
block (db-test/find-block-by-content @conn "old")
|
|
block-id (:db/id block)]
|
|
(ldb/transact! conn [[:db/add block-id :block/order "a0"]])
|
|
(ldb/batch-transact-with-temp-conn!
|
|
conn
|
|
{}
|
|
(fn [temp-conn]
|
|
(ldb/transact! temp-conn [[:db/add [:block/uuid (:block/uuid block)] :block/order "a1"]])))
|
|
(is (= "a1" (:block/order (d/entity @conn block-id)))))))
|
|
|
|
(deftest batch-transact-with-temp-conn-before-commit-can-abort-live-commit-test
|
|
(testing "before-commit runs after temp work and before the live conn is modified"
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks [{:page {:block/title "page 1"}
|
|
:blocks [{:block/title "old"}]}]})
|
|
block (db-test/find-block-by-content @conn "old")
|
|
block-id (:db/id block)]
|
|
(is (thrown? js/Error
|
|
(ldb/batch-transact-with-temp-conn!
|
|
conn
|
|
{}
|
|
(fn [temp-conn]
|
|
(ldb/transact! temp-conn [[:db/add [:block/uuid (:block/uuid block)] :block/title "new"]]))
|
|
{:before-commit #(throw (js/Error. "abort before commit"))})))
|
|
(is (= "old" (:block/title (d/entity @conn block-id)))))))
|
|
|
|
(deftest validated-transact-retries-when-live-conn-changes-before-commit-test
|
|
(testing "validated transact does not overwrite a concurrent live commit"
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks [{:page {:block/title "page 1"}
|
|
:blocks [{:block/title "first"}
|
|
{:block/title "second"}]}]})
|
|
first-block (db-test/find-block-by-content @conn "first")
|
|
second-block (db-test/find-block-by-content @conn "second")
|
|
injected? (atom false)
|
|
original-pipeline @ldb/*transact-pipeline-fn]
|
|
(try
|
|
(ldb/register-transact-pipeline-fn!
|
|
(fn [tx-report]
|
|
(when (and (not @injected?)
|
|
(some (fn [datom]
|
|
(and (= :block/title (:a datom))
|
|
(= "first updated" (:v datom))))
|
|
(:tx-data tx-report)))
|
|
(reset! injected? true)
|
|
(ldb/transact! conn [[:db/add (:db/id second-block) :block/title "second updated"]]))
|
|
tx-report))
|
|
(ldb/transact! conn [[:db/add (:db/id first-block) :block/title "first updated"]])
|
|
(is (= "first updated" (:block/title (d/entity @conn (:db/id first-block)))))
|
|
(is (= "second updated" (:block/title (d/entity @conn (:db/id second-block)))))
|
|
(finally
|
|
(reset! ldb/*transact-pipeline-fn original-pipeline))))))
|
|
|
|
(deftest fix-db-transact-runs-pipeline-without-recursive-validation-test
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks [{:page {:block/title "page 1"}
|
|
:blocks [{:block/title "before"}]}]})
|
|
block (db-test/find-block-by-content @conn "before")
|
|
block-id (:db/id block)
|
|
original-pipeline @ldb/*transact-pipeline-fn]
|
|
(try
|
|
(ldb/register-transact-pipeline-fn!
|
|
(fn [{:keys [db-before db-after tx-data tx-meta]}]
|
|
(let [stamp-report (d/with db-after
|
|
[{:db/id block-id
|
|
:block/tx-id 42}])]
|
|
(assoc stamp-report
|
|
:db-before db-before
|
|
:tx-data (concat tx-data (:tx-data stamp-report))
|
|
:tx-meta tx-meta))))
|
|
(with-redefs [db-validate/validate-tx-report
|
|
(fn [& _]
|
|
(throw (js/Error. "Repair transactions must not recursively validate")))]
|
|
(ldb/transact! conn
|
|
[{:db/id block-id
|
|
:block/title "after"}]
|
|
{:fix-db? true}))
|
|
(is (= "after" (:block/title (d/entity @conn block-id))))
|
|
(is (= 42 (:block/tx-id (d/entity @conn block-id)))
|
|
"Repair transactions commit canonical data produced by the pipeline.")
|
|
(finally
|
|
(reset! ldb/*transact-pipeline-fn original-pipeline)))))
|
|
|
|
(deftest test-batch-transact-clears-stale-tx-tail-before-next-store-tail
|
|
(let [block-uuid #uuid "00000001-2026-0421-0000-000000000000"
|
|
schema {:block/uuid {:db/unique :db.unique/identity}}
|
|
storage (make-storage)
|
|
conn (d/create-conn schema {:storage storage})]
|
|
;; Valid history: unique value moves to another entity.
|
|
(d/transact! conn [[:db/add 28446 :block/uuid block-uuid]])
|
|
(ldb/batch-transact!
|
|
conn
|
|
{}
|
|
(fn [batch-conn]
|
|
(d/transact! batch-conn [[:db/retract 28446 :block/uuid block-uuid]])
|
|
(d/transact! batch-conn [[:db/add 28447 :block/uuid block-uuid]])))
|
|
;; Next tx uses tail-only persistence.
|
|
(d/transact! conn [[:db/add 1 :filler "x"]])
|
|
(let [tail (get @(:*disk storage) @#'ds-storage/tail-addr)
|
|
stale? (some (fn [[e a v _tx]]
|
|
(and (= 28446 e)
|
|
(= :block/uuid a)
|
|
(= block-uuid v)))
|
|
(apply concat tail))
|
|
restored (d/restore-conn storage)]
|
|
(is (nil? stale?)
|
|
"stale pre-batch datoms should not leak into tail after batch-transact!")
|
|
(is (= [28447]
|
|
(mapv :e (d/datoms @restored :avet :block/uuid block-uuid)))))))
|
|
|
|
(deftest get-bidirectional-properties
|
|
(testing "disabled by default"
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:properties {:friend {:logseq.property/type :node
|
|
:build/property-classes [:Person]}}
|
|
:classes {:Person {}
|
|
:Project {}}
|
|
:pages-and-blocks
|
|
[{:page {:block/title "Alice"
|
|
:build/tags [:Person]
|
|
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}
|
|
{:page {:block/title "Bob"}}
|
|
{:page {:block/title "Charlie"
|
|
:build/tags [:Project]
|
|
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}]})
|
|
target (db-test/find-page-by-title @conn "Bob")]
|
|
(is (empty? (ldb/get-bidirectional-properties @conn (:db/id target))))))
|
|
|
|
(testing "enabled per class"
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:properties {:friend {:logseq.property/type :node
|
|
:build/property-classes [:Person]}}
|
|
:classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}}
|
|
:Project {}}
|
|
:pages-and-blocks
|
|
[{:page {:block/title "Alice"
|
|
:build/tags [:Person]
|
|
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}
|
|
{:page {:block/title "Bob"}}
|
|
{:page {:block/title "Charlie"
|
|
:build/tags [:Project]
|
|
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}]})
|
|
target (db-test/find-page-by-title @conn "Bob")
|
|
results (ldb/get-bidirectional-properties @conn (:db/id target))]
|
|
(is (= 1 (count results)))
|
|
(is (= "People" (:title (first results))))
|
|
(is (= ["Alice"]
|
|
(map :block/title (:entities (first results))))))))
|
|
|
|
(deftest get-bidirectional-properties-ignores-recycled-entities
|
|
(let [conn (db-test/create-conn-with-blocks
|
|
{:properties {:friend {:logseq.property/type :node
|
|
:build/property-classes [:Person]}}
|
|
:classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}}}
|
|
:pages-and-blocks
|
|
[{:page {:block/title "Alice"
|
|
:build/tags [:Person]
|
|
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}
|
|
{:page {:block/title "Bob"}}]})
|
|
alice (db-test/find-page-by-title @conn "Alice")
|
|
target (db-test/find-page-by-title @conn "Bob")]
|
|
(d/transact! conn [{:db/id (:db/id alice)
|
|
:logseq.property/deleted-at 1}])
|
|
(is (empty? (ldb/get-bidirectional-properties @conn (:db/id target))))))
|
|
|
|
(defn- bidirectional-perf-conn
|
|
[n property-titles]
|
|
(let [target-page {:page {:block/title "Target"}}
|
|
properties (into {}
|
|
(map (fn [property-title]
|
|
[property-title {:logseq.property/type :node
|
|
:build/property-classes [:Person]}]))
|
|
property-titles)
|
|
person-properties (into {}
|
|
(map (fn [property-title]
|
|
[property-title [:build/page {:block/title "Target"}]]))
|
|
property-titles)
|
|
pages (vec (concat [target-page]
|
|
(map (fn [i]
|
|
{:page {:block/title (str "Person " i)
|
|
:build/tags [:Person]
|
|
:build/properties person-properties}})
|
|
(range n))))]
|
|
(db-test/create-conn-with-blocks
|
|
{:properties properties
|
|
:classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}}}
|
|
:pages-and-blocks pages})))
|
|
|
|
(deftest ^:long get-bidirectional-properties-performance-single-property
|
|
(testing "attribute lookups scale with unique properties, not entities"
|
|
(let [conn (bidirectional-perf-conn 400 [:friend])
|
|
target-id (:db/id (db-test/find-page-by-title @conn "Target"))
|
|
original-entity d/entity
|
|
attr-lookups (atom 0)
|
|
results (with-redefs [d/entity (fn [db eid]
|
|
(when (keyword? eid)
|
|
(swap! attr-lookups inc))
|
|
(original-entity db eid))]
|
|
(ldb/get-bidirectional-properties @conn target-id))]
|
|
(is (= 1 (count results)))
|
|
(is (= 400 (count (:entities (first results)))))
|
|
(is (<= @attr-lookups 8)
|
|
(str "expected bounded attr lookups, got " @attr-lookups)))))
|
|
|
|
(deftest ^:long get-bidirectional-properties-performance-multi-property
|
|
(testing "attribute lookups stay bounded with multiple matching properties"
|
|
(let [conn (bidirectional-perf-conn 300 [:friend :colleague])
|
|
target-id (:db/id (db-test/find-page-by-title @conn "Target"))
|
|
original-entity d/entity
|
|
attr-lookups (atom 0)
|
|
results (with-redefs [d/entity (fn [db eid]
|
|
(when (keyword? eid)
|
|
(swap! attr-lookups inc))
|
|
(original-entity db eid))]
|
|
(ldb/get-bidirectional-properties @conn target-id))]
|
|
(is (= 1 (count results)))
|
|
(is (= 300 (count (:entities (first results)))))
|
|
(is (<= @attr-lookups 12)
|
|
(str "expected bounded attr lookups, got " @attr-lookups)))))
|