From d8996bd5e12e765bfe583f2fed654473c8d13f4b Mon Sep 17 00:00:00 2001 From: Dasemu Date: Wed, 26 Aug 2026 22:41:23 +0200 Subject: [PATCH] Fase B8+B9: stateful code kernel and live notes bound to external URLs B8 - executable code blocks with a real kernel: - worker/handler/kernel.cljs: one persistent SCI interpreter session per page, reused across block evaluations so defs/state survive between runs. Deliberately scoped to the same privileges the existing client-side single-shot evaluator already had (no shell/fs access, no call-api). - Run button + result display wired into block.cljs's code-block UI. - New :logseq.property.code/result and /executed-at properties (65.38). B9 - live notes: pages tagged #LiveNote that fetch external content on a refresh schedule and render it via the render-resource pipeline: - worker/handler/live_notes.cljs: per-block credential storage, scheduled refresh (Node-only), thread-apis for manual refresh and auth. - worker/handler/render_resource/live_note.cljs: reads back fetched content, watching [:entity block-uuid] for reactive invalidation. - New :logseq.class/LiveNote + :logseq.property.livenote/* properties (65.39). Bugs found and fixed along the way while actually exercising page creation through page-handler/create! for the first time in a test (previously only pure-fn tests existed for CalDAV/Event/Contact): - logseq-property-namespaces (property.cljs) was missing the citation/event/ contact/caldav/livenote namespaces, causing auto-create-ontology to treat already-built-in properties as new user idents. - outliner/page.cljs's build-page-tx always added the generic #Page tag even when the caller tagged with a built-in class that extends #Page, which malli-schema rejects as conflicting; it also lost the property type when building ref-typed property values for new pages, breaking non-:default types like :number. - entity-util/page? never recognized Event/Contact/LiveNote as pages (only Journal/Whiteboard/Tag/Property/Page had dedicated recognition), so pages tagged with those classes alone weren't treated as pages anywhere in the app; added built-in-page-child? alongside the existing per-class checks. - caldav.cljs's upsert-page! passed a bare keyword as a tag where an entity was required, and two error logs passed raw error objects to glogi that triggered runaway recursion in this test environment; both fixed. --- deps/db/src/logseq/db/frontend/class.cljs | 7 + .../src/logseq/db/frontend/entity_util.cljs | 17 +- deps/db/src/logseq/db/frontend/property.cljs | 35 +++- deps/db/src/logseq/db/frontend/schema.cljs | 2 +- deps/outliner/src/logseq/outliner/page.cljs | 31 +++- src/main/frontend/components/block.cljs | 36 ++++- src/main/frontend/components/live_note.cljs | 44 +++++ src/main/frontend/components/page.cljs | 4 + src/main/frontend/worker/db/migrate.cljs | 9 +- src/main/frontend/worker/db_core.cljs | 5 + src/main/frontend/worker/handler/caldav.cljs | 6 +- src/main/frontend/worker/handler/kernel.cljs | 99 ++++++++++++ .../frontend/worker/handler/live_notes.cljs | 149 +++++++++++++++++ .../handler/render_resource/engine.cljs | 2 + .../handler/render_resource/live_note.cljs | 20 +++ src/resources/dicts/en.edn | 9 ++ .../frontend/worker/handler/kernel_test.cljs | 86 ++++++++++ .../worker/handler/live_notes_test.cljs | 150 ++++++++++++++++++ 18 files changed, 698 insertions(+), 13 deletions(-) create mode 100644 src/main/frontend/components/live_note.cljs create mode 100644 src/main/frontend/worker/handler/kernel.cljs create mode 100644 src/main/frontend/worker/handler/live_notes.cljs create mode 100644 src/main/frontend/worker/handler/render_resource/live_note.cljs create mode 100644 src/test/frontend/worker/handler/kernel_test.cljs create mode 100644 src/test/frontend/worker/handler/live_notes_test.cljs diff --git a/deps/db/src/logseq/db/frontend/class.cljs b/deps/db/src/logseq/db/frontend/class.cljs index bfa0551..db95be1 100644 --- a/deps/db/src/logseq/db/frontend/class.cljs +++ b/deps/db/src/logseq/db/frontend/class.cljs @@ -134,6 +134,13 @@ :schema {:properties [:logseq.property.contact/email :logseq.property.contact/phone :logseq.property.contact/organization]}} + :logseq.class/LiveNote + {:title "Live Note" + :properties {:logseq.property.class/extends :logseq.class/Page + :logseq.property/icon {:type :tabler-icon :id "refresh"}} + :schema {:properties [:logseq.property.livenote/url :logseq.property.livenote/refresh-minutes] + :required-properties [:logseq.property.livenote/url]}} + ;; TODO: Add more classes such as :book, :paper, :movie, :music) ))) diff --git a/deps/db/src/logseq/db/frontend/entity_util.cljs b/deps/db/src/logseq/db/frontend/entity_util.cljs index 6300286..91625f5 100644 --- a/deps/db/src/logseq/db/frontend/entity_util.cljs +++ b/deps/db/src/logseq/db/frontend/entity_util.cljs @@ -42,12 +42,27 @@ [entity] (has-tag? entity :logseq.class/Journal)) +;; Built-in classes that extend :logseq.class/Page (logseq.db.frontend.class/ +;; page-children-classes) but, like Journal, are recognized as pages by their +;; own specific tag alone -- they must not also carry the generic #Page tag +;; (malli-schema's block-tags rule forbids combining #Page with another +;; built-in page tag). Can't derive this set from class.cljs's +;; page-children-classes here: class.cljs requires this namespace, so the +;; reverse require would be circular. +(def ^:private built-in-page-child-tags + #{:logseq.class/Event :logseq.class/Contact :logseq.class/LiveNote}) + +(defn built-in-page-child? + [entity] + (some (fn [tag-ident] (has-tag? entity tag-ident)) built-in-page-child-tags)) + (defn page? [entity] (or (internal-page? entity) (journal? entity) (class? entity) - (property? entity))) + (property? entity) + (built-in-page-child? entity))) (defn asset? "Given an entity or map, check if it is an asset block" diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index b44ef83..ce17676 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -151,6 +151,16 @@ :hide? true :view-context :block} :queryable? true} + :logseq.property.code/result {:title "Code result" + :schema {:type :string + :public? false + :hide? true + :view-context :block}} + :logseq.property.code/executed-at {:title "Code executed at" + :schema {:type :datetime + :public? false + :hide? true + :view-context :block}} :logseq.property/default-value {:title "Default value" :schema {:type :entity :public? false @@ -452,6 +462,27 @@ :hide? true :public? false}} + :logseq.property.livenote/url {:title "Source URL" + :schema {:type :url + :public? true}} + :logseq.property.livenote/refresh-minutes {:title "Refresh every (minutes)" + :schema {:type :number + :public? true} + :properties {:logseq.property/default-value 60}} + ;; Live note bookkeeping, not user-facing + :logseq.property.livenote/content {:title "Live note content" + :schema {:type :string + :hide? true + :public? false}} + :logseq.property.livenote/last-fetched-at {:title "Live note last fetched at" + :schema {:type :datetime + :hide? true + :public? false}} + :logseq.property.livenote/last-error {:title "Live note last error" + :schema {:type :string + :hide? true + :public? false}} + :logseq.property/icon {:title "Icon" :schema {:type :map}} :logseq.property/publishing-public? {:title "Publishing Public?" @@ -804,7 +835,9 @@ "logseq.property.journal" "logseq.property.class" "logseq.property.view" "logseq.property.user" "logseq.property.history" "logseq.property.reaction" "logseq.property.sync" "logseq.property.publish" - "logseq.property.recycle" "logseq.property.comments" "logseq.property.agent"}) + "logseq.property.recycle" "logseq.property.comments" "logseq.property.agent" + "logseq.property.citation" "logseq.property.event" "logseq.property.contact" + "logseq.property.caldav" "logseq.property.livenote"}) (defn logseq-property? "Determines if keyword is a logseq property" diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs index d316a02..10a146a 100644 --- a/deps/db/src/logseq/db/frontend/schema.cljs +++ b/deps/db/src/logseq/db/frontend/schema.cljs @@ -30,7 +30,7 @@ (map (juxt :major :minor) [(parse-schema-version x) (parse-schema-version y)]))) -(def version (parse-schema-version "65.37")) +(def version (parse-schema-version "65.39")) (defn major-version "Return a number. diff --git a/deps/outliner/src/logseq/outliner/page.cljs b/deps/outliner/src/logseq/outliner/page.cljs index cf68c7c..606081b 100644 --- a/deps/outliner/src/logseq/outliner/page.cljs +++ b/deps/outliner/src/logseq/outliner/page.cljs @@ -154,10 +154,33 @@ (ldb/transact! conn tx-data tx-meta')) true)))))) +(defn- tag-ident + [tag] + (cond (de/entity? tag) (:db/ident tag) + (keyword? tag) tag + (map? tag) (:db/ident tag))) + (defn- build-page-tx [db properties page {:keys [class? tags class-ident-namespace]}] (when (:block/uuid page) (let [type-tag (if class? :logseq.class/Tag :logseq.class/Page) - tags' (if (:block/journal-day page) tags (conj tags type-tag)) + ;; A page already tagged with a built-in class that extends #Page + ;; (Journal, Whiteboard, Event, Contact, Project, Area, ...) must + ;; not also carry the generic #Page tag -- malli-schema rejects + ;; that combination as conflicting built-in page tags. + already-page-tagged? (and (= type-tag :logseq.class/Page) + (some (fn [tag] + (let [ident (tag-ident tag)] + (and ident + (or (= ident :logseq.class/Page) + (contains? db-class/page-children-classes ident))))) + tags)) + tags' (if (or (:block/journal-day page) already-page-tagged?) tags (conj tags type-tag)) + ;; page-name->map may have already seeded :block/tags with the + ;; generic #Page tag -- strip it back out here too, or it survives + ;; the merge below even though tags' no longer re-adds it. + page (cond-> page + already-page-tagged? + (update :block/tags (fn [existing] (remove #{:logseq.class/Page} existing)))) page' (update page :block/tags (fnil into []) (mapv (fn [tag] @@ -181,10 +204,10 @@ page' (->> properties (keep (fn [[k v]] - ;; TODO: Pass in property type in order to support property - ;; types other than :default (when (db-property/built-in-has-ref-value? k) - [k v]))) + [{:db/ident k + :logseq.property/type (get-in db-property/built-in-properties [k :schema :type])} + v]))) (into {})))] (cond-> (if class? [(merge (db-class/build-new-class db page' {:ident-namespace class-ident-namespace}) diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index 0f2cb49..bff0ce5 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -4714,6 +4714,34 @@ (on-select! lang e))) (shui/popup-hide!))})))) +(hsx/defc kernel-run-button + [block] + (let [[running? set-running!] (hooks/use-state false)] + (shui/button + {:variant :text + :size :sm + :disabled running? + :on-click + (fn [^js e] + (util/stop-propagation e) + (let [repo (state/get-current-repo) + page-uuid (or (:block/uuid (:block/page block)) (:block/uuid block)) + ^js cm (util/get-cm-instance (util/rec-get-node (.-target e) "ls-block"))] + (when (and repo page-uuid cm) + (set-running! true) + (-> (state/ (state/updates)))] diff --git a/src/main/frontend/worker/db_core.cljs b/src/main/frontend/worker/db_core.cljs index 07a2af5..fbf138b 100644 --- a/src/main/frontend/worker/db_core.cljs +++ b/src/main/frontend/worker/db_core.cljs @@ -18,6 +18,8 @@ [frontend.worker.handler.export] [frontend.worker.handler.flashcard] [frontend.worker.handler.graph] + [frontend.worker.handler.kernel :as kernel-handler] + [frontend.worker.handler.live-notes :as live-notes-handler] [frontend.worker.handler.markdown] [frontend.worker.handler.maintenance] [frontend.worker.handler.page] @@ -366,6 +368,8 @@ (js/clearInterval timer)) (swap! *client-ops-cleanup-timers dissoc repo) (caldav-handler/stop-sync! repo) + (kernel-handler/clear-repo-sessions! repo) + (live-notes-handler/stop-refresh! repo) (swap! *sqlite-conns dissoc repo) (when-let [vector-index (worker-state/get-vector-index repo)] (when-let [close-fn (:close! vector-index)] @@ -651,6 +655,7 @@ (db-listener/listen-db-changes! repo conn) (caldav-handler/maybe-start-sync! repo) + (live-notes-handler/maybe-start-refresh! repo) nil)))))) diff --git a/src/main/frontend/worker/handler/caldav.cljs b/src/main/frontend/worker/handler/caldav.cljs index 4bd2ff1..24b58ac 100644 --- a/src/main/frontend/worker/handler/caldav.cljs +++ b/src/main/frontend/worker/handler/caldav.cljs @@ -371,7 +371,7 @@ (when (seq tx) (ldb/transact! conn tx)) (:block/uuid existing)) (let [[_title page-uuid] (page-handler/create! conn title - {:tags [tag-ident] + {:tags [(d/entity db [:db/ident tag-ident])] :properties (clean-properties properties)})] page-uuid)))) @@ -521,7 +521,7 @@ (p/catch (fn [error] (swap! *sync-status assoc repo {:last-sync-at (js/Date.now()) :last-sync-error (str error)}) - (log/error :caldav/sync-failed {:repo repo :error error}) + (log/error :caldav/sync-failed {:repo repo :error (str error)}) {:status :error :error (str error)})))))))) (defn- node-runtime? @@ -548,7 +548,7 @@ (swap! *sync-intervals assoc repo (js/setInterval #(= (- now-ms last-fetched-at) + (* (effective-refresh-minutes refresh-minutes) 60 1000)))) + +(defn truncate-content + [text] + (when (string? text) + (if (> (count text) max-content-length) + (str (subs text 0 max-content-length) "\n...(truncated)") + text))) + +(defn- (fetch-fn url (clj->js (cond-> {:method "GET"} + auth-header (assoc :headers {"Authorization" auth-header})))) + (p/then (fn [resp] + (if (.-ok resp) + (.text resp) + (p/rejected (ex-info (str "HTTP " (.-status resp)) {:status (.-status resp)}))))))) + +(defn- (p/let [auth-header (> (d/datoms db :avet :block/tags :logseq.class/LiveNote) + (map (fn [datom] (d/entity db (:e datom)))) + (remove #(or (:logseq.property/deleted-at %) (ldb/hidden? %))) + (filter :logseq.property.livenote/url)))) + +(defn- due-pages + [now-ms pages] + (filter (fn [page] + (due? now-ms + (:logseq.property.livenote/last-fetched-at page) + (:logseq.property.livenote/refresh-minutes page))) + pages)) + +(defn n 1) " blocks" " block"))) :editor/new-page "New page" :editor/new-tag "New tag" @@ -504,6 +505,8 @@ :editor/paste "Paste" :editor/reference-node-use-page-ref "To reference a node, please use `[[]]`." :editor/remove-heading "Remove heading" + :editor/run-code "Run" + :editor/running-code "Running..." :editor/search-for-node "Search for a node" :editor/search-for-tag "Search for a tag" :editor/search-template-placeholder "Search for a template" @@ -1012,6 +1015,12 @@ :library/add-pages "Add pages" :library/title "Library" + :live-note/last-fetched "Last fetched:" + :live-note/not-fetched-yet "Not fetched yet" + :live-note/refresh-now "Refresh now" + :live-note/refreshing "Refreshing…" + :live-note/title "Live note" + :mobile/camera-access-denied "Camera access is denied. Enable it in Settings > Logseq." :mobile/microphone-access-denied "Microphone access is denied. Enable it in Settings > Logseq." diff --git a/src/test/frontend/worker/handler/kernel_test.cljs b/src/test/frontend/worker/handler/kernel_test.cljs new file mode 100644 index 0000000..09baaf4 --- /dev/null +++ b/src/test/frontend/worker/handler/kernel_test.cljs @@ -0,0 +1,86 @@ +(ns frontend.worker.handler.kernel-test + (:require [cljs.test :refer [deftest is testing use-fixtures]] + [frontend.worker.handler.kernel :as kernel] + [frontend.worker.state :as worker-state] + [logseq.db.test.helper :as db-test])) + +(def ^:private test-repo "kernel-test-repo") + +(defn- with-test-conn + [f] + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Kernel page"} + :blocks [{:block/title "code block"}]}]}) + prev-conns @worker-state/*datascript-conns] + (swap! worker-state/*datascript-conns assoc test-repo conn) + (try + (f conn) + (finally + (reset! worker-state/*datascript-conns prev-conns) + (kernel/clear-repo-sessions! test-repo))))) + +(use-fixtures :each + {:after #(kernel/clear-repo-sessions! test-repo)}) + +(deftest eval-block-persists-result-onto-the-block + (with-test-conn + (fn [conn] + (let [block (db-test/find-block-by-content @conn "code block") + page-uuid (:block/uuid (:block/page block)) + result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(+ 1 2)")] + (is (= "3" (:result result))) + (is (false? (:error? result))) + (let [updated (db-test/find-block-by-content @conn "code block")] + (is (= "3" (:logseq.property.code/result updated))) + (is (number? (:logseq.property.code/executed-at updated)))))))) + +(deftest eval-block-formats-errors-with-a-prefix + (with-test-conn + (fn [conn] + (let [block (db-test/find-block-by-content @conn "code block") + page-uuid (:block/uuid (:block/page block)) + result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(throw (ex-info \"boom\" {}))")] + (is (true? (:error? result))) + (is (re-find #"^Error: " (:result result))))))) + +(deftest kernel-session-state-persists-across-evaluations-on-the-same-page + (with-test-conn + (fn [conn] + (let [block (db-test/find-block-by-content @conn "code block") + page-uuid (:block/uuid (:block/page block))] + (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0)") + (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter (inc counter))") + (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter (inc counter))") + (let [result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "counter")] + (is (= "2" (:result result)))))))) + +(deftest kernel-sessions-are-isolated-per-page + (with-test-conn + (fn [conn] + (let [block (db-test/find-block-by-content @conn "code block") + page-a (random-uuid) + page-b (random-uuid)] + (kernel/eval-block! test-repo page-a (:block/uuid block) "(def shared 1)") + (let [result (kernel/eval-block! test-repo page-b (:block/uuid block) "(def shared 2) shared")] + (is (= "2" (:result result)))) + (let [result (kernel/eval-block! test-repo page-a (:block/uuid block) "shared")] + (is (= "1" (:result result)))))))) + +(deftest restart-session-clears-persisted-state + (with-test-conn + (fn [conn] + (let [block (db-test/find-block-by-content @conn "code block") + page-uuid (:block/uuid (:block/page block))] + (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0)") + (kernel/restart-session! test-repo page-uuid) + (let [result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0) counter")] + (is (= "0" (:result result)))))))) + +(deftest kernel-namespace-exposes-the-same-helpers-as-the-client-evaluator + (with-test-conn + (fn [conn] + (let [block (db-test/find-block-by-content @conn "code block") + page-uuid (:block/uuid (:block/page block))] + (is (= "6" (:result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(sum [1 2 3])")))) + (is (= "2" (:result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(average [1 2 3])")))))))) diff --git a/src/test/frontend/worker/handler/live_notes_test.cljs b/src/test/frontend/worker/handler/live_notes_test.cljs new file mode 100644 index 0000000..b28948d --- /dev/null +++ b/src/test/frontend/worker/handler/live_notes_test.cljs @@ -0,0 +1,150 @@ +(ns frontend.worker.handler.live-notes-test + (:require [cljs.test :refer [async deftest is testing]] + [datascript.core :as d] + [frontend.worker.handler.live-notes :as live-notes] + [frontend.worker.handler.page :as page-handler] + [frontend.worker.platform :as platform] + [frontend.worker.state :as worker-state] + [logseq.db.test.helper :as db-test] + [promesa.core :as p])) + +(deftest effective-refresh-minutes-clamps-to-a-minimum + (is (= 60 (live-notes/effective-refresh-minutes nil))) + (is (= 60 (live-notes/effective-refresh-minutes 60))) + (is (= 5 (live-notes/effective-refresh-minutes 1))) + (is (= 30 (live-notes/effective-refresh-minutes 30)))) + +(deftest due?-is-true-when-never-fetched + (is (true? (live-notes/due? 1000000 nil 60))) + (is (true? (live-notes/due? 1000000 "not-a-number" 60)))) + +(deftest due?-compares-elapsed-time-against-the-refresh-interval + (let [now 10000000 + one-hour-ms (* 60 60 1000)] + (testing "not yet due" + (is (false? (live-notes/due? now (- now (/ one-hour-ms 2)) 60)))) + (testing "exactly due" + (is (true? (live-notes/due? now (- now one-hour-ms) 60)))) + (testing "overdue" + (is (true? (live-notes/due? now (- now (* 2 one-hour-ms)) 60)))))) + +(deftest truncate-content-leaves-short-text-untouched + (is (= "short" (live-notes/truncate-content "short"))) + (is (nil? (live-notes/truncate-content nil)))) + +(deftest truncate-content-truncates-long-text-with-a-marker + (let [long-text (apply str (repeat 9000 "a")) + result (live-notes/truncate-content long-text)] + (is (< (count result) (count long-text))) + (is (re-find #"\(truncated\)$" result)))) + +(def ^:private test-repo "live-notes-test-repo") + +;; :url is a ref-typed property (its value is wrapped in a property-value +;; entity, per db-property-build/build-properties-with-ref-values), so it +;; can't be set with a raw [:db/add eid attr string] tuple, and :build/tags +;; + :build/properties on create-conn-with-blocks' page map only recognizes +;; property/class namespaces curated into logseq-property-namespaces/ +;; db-class/logseq-class? -- going through page-handler/create! (the same +;; path caldav.cljs's upsert-page! uses to create tagged pages with +;; properties) exercises the real ref-value-building pipeline instead. +(defn- with-test-conn + [f] + (let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []}) + _ (page-handler/create! conn "Weather" + {:tags [(d/entity @conn [:db/ident :logseq.class/LiveNote])] + :properties {:logseq.property.livenote/url "https://example.com/weather" + :logseq.property.livenote/refresh-minutes 60}}) + _ (page-handler/create! conn "Plain page" {}) + prev-conns @worker-state/*datascript-conns] + (swap! worker-state/*datascript-conns assoc test-repo conn) + (try + (f conn) + (finally + (reset! worker-state/*datascript-conns prev-conns) + (live-notes/stop-refresh! test-repo))))) + +(defn- fake-fetch + [response-text] + (fn [_url _opts] + (js/Promise.resolve + #js {:ok true + :text (fn [] (js/Promise.resolve response-text))}))) + +(defn- failing-fetch + [_url _opts] + (js/Promise.resolve #js {:ok false :status 500 :text (fn [] (js/Promise.resolve ""))})) + +(def ^:private test-platform + {:env {:publishing? false :runtime :node} + :storage {} + :kv {:get (fn [_] nil) :set! (fn [_ _] nil)} + :broadcast {:post-message! (fn [& _] nil)} + :websocket {:connect (fn [_] #js {})} + :sqlite {} + :crypto {:save-secret-text! (fn [_key _text] (p/resolved nil)) + :read-secret-text (fn [_key] (p/resolved nil)) + :delete-secret-text! (fn [_key] (p/resolved nil))} + :timers {:set-interval! (fn [_ _] nil)}}) + +;; live-notes/ (f) + (p/catch (fn [e] (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (reset! @#'platform/*platform platform-prev) + (done)))))) + +(deftest tick-fetches-due-live-notes-and-transacts-content + (async done + (with-stub-platform + (fn [] + (with-test-conn + (fn [conn] + (-> (live-notes/ (live-notes/ (live-notes/