Fase 9 (1/N): re-host the semantic API onto the daemon

Ports the 38-operation semantic API from deps/db-sync's Cloudflare Worker
handler (handler/semantic.cljs + routes/semantic.cljs) onto the daemon's
own authoritative conn, per the plan's Fase 9. deps/db-sync itself is
untouched -- this only adds the daemon-side equivalent.

New:
- worker/semantic_routes.cljs: route table + OpenAPI 3.1.0 document
  generator, adapted from the CF-Worker's routes/semantic.cljs. Drops the
  :graph-id URL segment and listGraphs operation entirely -- the daemon is
  bound to a single repo per process (see db_worker_node.cljs's
  bound-repo), unlike the CF-Worker's per-graph Durable Objects, so every
  request implicitly targets the daemon's own bound repo. Security scheme
  is a bearer token (the daemon's actual session auth) instead of OAuth2.
- worker/handler/semantic.cljs: the 38 operations themselves, ported from
  the CF-Worker's handler/semantic.cljs. Adaptations from the original:
  * ensure-conn! (lazy SQLite conn open) -> worker.state/get-datascript-conn
    (the daemon already holds the authoritative conn for its bound repo).
  * broadcast-change! calls dropped entirely -- any transact! against the
    conn is already picked up by the listener worker.db-listener installs,
    which broadcasts to every connected client, so nothing here needs to
    push manually.
  * HTTP body reads (p/let + body-clj) dropped -- bodies arrive already
    JSON-parsed from the HTTP layer, and every operation is synchronous
    (DataScript itself is synchronous), so none of this needs promises.
  * http/bad-request, http/not-found, js/Response error helpers -> ex-info
    with :status/:code, matching every other thread-api in this codebase.
  * R2 asset storage -> platform/asset-write-bytes! (local filesystem),
    using the <uuid>.<type> naming convention components/block/asset.cljs
    already establishes. R2 signed URLs -> server-relative
    /v1/assets/:repo/:name paths (already served, with ETag caching, by
    the existing Fase 5 asset routes) since bearer-token auth already
    gates those with the same ?token= fallback semantic GET routes need.
  Registered as a single :thread-api/semantic-request thread-api
  dispatched by handler keyword, mirroring how the CF-Worker original
  dispatched via one big `case` in `handle`.
- db_worker_node.cljs: wires a /v1/semantic/* branch (JSON body parsed
  for most operations; raw bytes read directly for assets-create,
  mirroring the existing /v1/assets/:repo/:name PUT route) plus
  /v1/semantic/openapi.json. Unlike /v1/invoke -- an internal RPC
  transport whose transit-encoded results Logseq's own clients already
  know how to decode -- /v1/semantic/* is the public HTTP API documented
  by the OpenAPI doc and meant for arbitrary HTTP/MCP clients, so results
  are decoded server-side into plain JSON, the same way the existing
  /v1/assets/:repo/:name GET route already does for the other route with
  external HTTP consumers.
- src/test/frontend/worker/semantic_test.cljs: exercises pages,
  blocks/properties, capture, tags/tasks, search, the OpenAPI document,
  401-without-auth, and 404-on-unknown-route against a live daemon over
  real HTTP. 7 tests, 31 assertions.

Verification: all 5 shadow-cljs targets compile with 0 warnings. Full
`bb dev:test` run has zero regressions -- the only failures are the same
7 pre-existing, sandboxed-network-related persist_db_test.cljs failures
(ECONNREFUSED / Invalid URL) already documented as environmental in the
Fase 8 commit.

Found and fixed in-session: semantic_test.cljs originally set its own
LOGSEQ_DAEMON_USERNAME/PASSWORD env vars at namespace load time, colliding
with db_worker_node_test.cljs doing the same (both get bundled into the
same test.js process) -- whichever namespace loaded last won the race and
locked the other file's daemons out of their own bootstrapped credential,
cascading into ~150 unrelated failures. Fixed by reusing the same literal
credential constants instead of introducing a second one.

Known follow-up, not yet done: the plan's "consolidar MCP" half of Fase 9
-- an MCP JSON-RPC endpoint on the daemon (the CF-Worker's equivalent,
deps/db-sync/worker/entry.mjs, leans on @cloudflare/codemode's
openApiMcpServer plus ChatGPT-app-specific tooling that doesn't port to
Node) and the decision on electron/mcp_server.cljs's fate are still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 16:34:14 +02:00
parent 1e7e9a3c13
commit 196de21731
5 changed files with 1618 additions and 0 deletions

View File

@@ -24,6 +24,7 @@
[frontend.worker.handler.query]
[frontend.worker.handler.render-resource.engine]
[frontend.worker.handler.search :as search-handler]
[frontend.worker.handler.semantic]
[frontend.worker.handler.transaction :as transaction-handler]
[frontend.worker.handler.undo-redo]
[frontend.worker.handler.view]

View File

@@ -9,6 +9,7 @@
[frontend.worker.db-worker-node-lock :as db-lock]
[frontend.worker.db-worker-node-ws :as db-ws]
[frontend.worker.platform.node :as platform-node]
[frontend.worker.semantic-routes :as semantic-routes]
[frontend.worker.state :as worker-state]
[lambdaisland.glogi :as log]
[logseq.common.graph-dir :as graph-dir]
@@ -432,6 +433,52 @@
[^js buf]
(str "\"" (-> (crypto/createHash "sha1") (.update buf) (.digest "hex")) "\""))
(defn- query-params-map
"Ring/bidi-style :page-id params come from semantic-routes' reitit router
already keyword-keyed; the query string doesn't, so build it the same way
by hand from URLSearchParams -- there's no js->clj for it."
[^js parsed-url]
(into {}
(map (fn [pair] [(keyword (aget pair 0)) (aget pair 1)]))
(js/Array.from (.entries (.-searchParams parsed-url)))))
(defn- <read-json-body-if-present
[req method]
(if (contains? #{"GET" "DELETE"} method)
(p/resolved nil)
(p/let [body (<read-body req)]
(when-not (string/blank? body)
(js->clj (js/JSON.parse body) :keywordize-keys true)))))
(defn- semantic-request-handler
"/v1/semantic/* -- unlike /v1/invoke (an internal RPC transport that hands
transit-encoded results straight back to Logseq's own clients, which
already know how to decode them), this is the public HTTP API documented
by semantic-routes/openapi-document and consumed by arbitrary HTTP/MCP
clients, so results are decoded server-side into plain JSON matching the
OpenAPI schemas -- the same shape the /v1/assets/:repo/:name GET route
already produces for the one other route with external HTTP consumers."
[^js req ^js res bound-repo proxy parsed-url request-path method]
(if-let [{:keys [handler success-status path-params]} (semantic-routes/match method request-path)]
(let [query-params (query-params-map parsed-url)
method-kw :thread-api/semantic-request
method-str (normalize-method-str method-kw)]
(-> (p/let [body (if (= handler :semantic/assets-create)
(<read-body-buffer req)
(<read-json-body-if-present req method))
args [bound-repo handler path-params query-params body]]
(if-let [{:keys [status error]} (repo-error method-kw args bound-repo)]
(send-json! res status {:ok false :error error})
(p/let [_ (let [{:keys [path lock]} @*lock-info]
(db-lock/assert-lock-owner! path lock))
result-transit (<invoke! proxy method-str method-kw args)
result (ldb/read-transit-str result-transit)]
(if (nil? result)
(send-no-content! res)
(send-json! res success-status result)))))
(p/catch (fn [error] (log-invoke-error! res error handler)))))
(send-json! res 404 {:ok false :error {:code :not-found :message "not found"}})))
(defn- <process-batch-invoke!
"One entry of a /v1/invoke-batch request, run through the same
repo-check/lock/invoke path as /v1/invoke but never rejecting -- a failed
@@ -623,6 +670,12 @@
(log-invoke-error! res error :thread-api/asset-write-bytes))))
(send-json! res 400 {:ok false :error {:code :invalid-request :message "invalid asset path"}}))
(= request-path "/v1/semantic/openapi.json")
(send-json! res 200 (semantic-routes/openapi-document {:repo bound-repo}))
(string/starts-with? request-path "/v1/semantic/")
(semantic-request-handler req res bound-repo proxy parsed-url request-path method)
(= request-path "/v1/invoke-batch")
(if (= method "POST")
(-> (p/let [body (<read-body req)

View File

@@ -0,0 +1,945 @@
(ns frontend.worker.handler.semantic
"Semantic API operations (Fase 9), ported from
deps/db-sync/src/logseq/db_sync/worker/handler/semantic.cljs. That version
opened a DataScript conn over a Cloudflare Durable Object's own SQLite and
broadcast changes to WS clients explicitly; the daemon already has the
authoritative conn for its bound repo (worker.state/get-datascript-conn)
and any transact! against it is picked up by the conn listener installed
in worker.db-listener, which already broadcasts to every connected client
-- so unlike the CF-Worker version, nothing here calls broadcast
explicitly. Bodies arrive here already JSON-parsed by the HTTP layer (see
db_worker_node.cljs's semantic-request branch), so there is no async body
read either: every operation below is synchronous, matching how DataScript
itself is synchronous.
Registers a single :thread-api/semantic-request, dispatched over by
handler keyword (the same handler keywords semantic-routes/match
resolves), rather than one def-thread-api per operation -- the CF-Worker
original used one big `case` in the same way, and callers already go
through the route table to get the handler keyword."
(:require [clojure.string :as string]
[datascript.core :as d]
[frontend.common.thread-api :refer [def-thread-api]]
[frontend.worker.platform :as platform]
[frontend.worker.state :as worker-state]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]
[logseq.db.frontend.asset :as db-asset]
[logseq.db.frontend.content :as db-content]
[logseq.db.frontend.db :as db-db]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.property.type :as db-property-type]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.page :as outliner-page]
[logseq.outliner.property :as outliner-property]
[logseq.outliner.tree :as otree]))
(def ^:private default-limit 50)
(def ^:private max-limit 200)
(def ^:private max-asset-size (* 100 1024 1024))
(defn- api-error
([status code message] (api-error status code message nil))
([status code message data]
(ex-info message (merge {:status status :code code} data))))
(defn- bad-request [message] (throw (api-error 400 :bad-request message)))
(defn- not-found [] (throw (api-error 404 :not-found "not found")))
(defn- api-error! [message status] (throw (api-error status :semantic-error message)))
(defn- uuid-string [value]
(when value (str value)))
(defn- page? [entity]
(or (ldb/internal-page? entity) (ldb/journal? entity)))
(defn- asset? [entity]
(string? (:logseq.property.asset/type entity)))
(defn- entity-ident [entity]
(when-let [ident (:db/ident entity)]
(str (namespace ident) "/" (name ident))))
(defn- tag? [entity]
(ldb/class? entity))
(defn- property? [entity]
(ldb/property? entity))
(defn- block-kind [entity]
(cond
(asset? entity) "asset"
(property? entity) "property"
(tag? entity) "tag"
(page? entity) "page"
:else "block"))
(defn- block-response [block]
(cond-> {:uuid (uuid-string (:block/uuid block))
:kind (block-kind block)
:title (:block/title block)
:order (:block/order block)}
(:block/parent block) (assoc :parent-id (uuid-string (:block/uuid (:block/parent block))))
(:block/page block) (assoc :page-id (uuid-string (:block/uuid (:block/page block))))
(seq (:block/children block)) (assoc :children (mapv block-response (:block/children block)))))
(defn- tag-response [entity]
{:uuid (uuid-string (:block/uuid entity))
:ident (entity-ident entity)
:title (:block/title entity)})
(defn- property-choice-response [entity]
{:uuid (uuid-string (:block/uuid entity))
:ident (entity-ident entity)
:title (:block/title entity)
:value (:logseq.property/value entity)})
(defn- property-response [entity]
(cond-> {:uuid (uuid-string (:block/uuid entity))
:ident (entity-ident entity)
:title (:block/title entity)
:type (some-> (:logseq.property/type entity) name)
:cardinality (some-> (:db/cardinality entity) name)}
(seq (:block/_closed-value-property entity))
(assoc :choices (mapv property-choice-response (:block/_closed-value-property entity)))))
(defn- asset-response [entity]
{:uuid (uuid-string (:block/uuid entity))
:title (:block/title entity)
:type (:logseq.property.asset/type entity)
:size (:logseq.property.asset/size entity)
:checksum (:logseq.property.asset/checksum entity)})
(defn- asset-file-name [entity]
(str (:block/uuid entity) "." (:logseq.property.asset/type entity)))
(defn- entities-by-avet [db attribute & [value]]
(->> (if (some? value)
(d/datoms db :avet attribute value)
(d/datoms db :avet attribute))
(map #(d/entity db (:e %)))
(remove entity-util/hidden?)))
(defn- parse-limit [{:keys [limit]}]
(let [parsed (when limit (js/parseInt limit 10))]
(cond
(nil? limit) default-limit
(or (js/isNaN parsed) (not (pos? parsed))) nil
:else (min parsed max-limit))))
(def ^:private text-encoder (js/TextEncoder.))
(def ^:private text-decoder (js/TextDecoder.))
(defn- encode-cursor [key]
(let [payload (.encode text-encoder (js/JSON.stringify (clj->js key)))
binary (.join (.map (js/Array.from payload) #(js/String.fromCharCode %)) "")]
(js/btoa binary)))
(defn- decode-cursor [value]
(when value
(try
(let [binary (js/atob value)
payload (js/Uint8Array.from (js/Array.from binary #(.charCodeAt % 0)))
decoded (js->clj (js/JSON.parse (.decode text-decoder payload)))]
(when (and (vector? decoded) (= 2 (count decoded)) (every? string? decoded)) decoded))
(catch :default _ nil))))
(defn- sort-key [item]
[(or (:order item) (:block/order item)
(string/lower-case (or (:title item) (:block/title item) "")))
(str (or (:uuid item) (:block/uuid item) ""))])
(defn- paginate [items limit cursor]
(let [sorted (sort-by sort-key items)
remaining (if cursor (drop-while #(not (pos? (compare (sort-key %) cursor))) sorted) sorted)
selected (vec (take (inc limit) remaining))
more? (> (count selected) limit)
page (vec (take limit selected))]
(cond-> {:items page}
more? (assoc :next-cursor (encode-cursor (sort-key (last page)))))))
(defn- paginate-bounded [items limit cursor]
(let [selected (reduce (fn [selected item]
(if (or (nil? cursor)
(pos? (compare (sort-key item) cursor)))
(->> (conj selected item)
(sort-by sort-key)
(take (inc limit))
vec)
selected))
[]
items)
more? (> (count selected) limit)
page (vec (take limit selected))]
(cond-> {:items page}
more? (assoc :next-cursor (encode-cursor (sort-key (last page)))))))
(defn- pagination-options [query-params]
{:limit (parse-limit query-params)
:raw-cursor (:cursor query-params)
:cursor (decode-cursor (:cursor query-params))})
(defn- parse-timestamp [raw]
(cond
(nil? raw) nil
(not (re-matches #"\d+" raw)) ::invalid
:else (js/parseInt raw 10)))
(defn- time-filter-options [query-params]
(let [created-after (parse-timestamp (:created-after query-params))
updated-after (parse-timestamp (:updated-after query-params))]
{:created-after created-after
:updated-after updated-after
:invalid? (or (= ::invalid created-after) (= ::invalid updated-after))}))
(defn- after-time-filters? [entity {:keys [created-after updated-after]}]
(and (or (nil? created-after)
(and (number? (:block/created-at entity))
(> (:block/created-at entity) created-after)))
(or (nil? updated-after)
(and (number? (:block/updated-at entity))
(> (:block/updated-at entity) updated-after)))))
(defn- paginated-response
([query-params response-key items]
(paginated-response query-params response-key items identity))
([query-params response-key items response-fn]
(paginated-response query-params response-key items response-fn paginate))
([query-params response-key items response-fn paginate-fn]
(let [{:keys [limit raw-cursor cursor]} (pagination-options query-params)
time-filters (time-filter-options query-params)]
(cond
(nil? limit) (bad-request "invalid limit")
(and raw-cursor (nil? cursor)) (bad-request "invalid cursor")
(:invalid? time-filters) (bad-request "invalid time filter")
:else (let [items (filter #(after-time-filters? % time-filters) items)
{:keys [items next-cursor]} (paginate-fn items limit cursor)]
(cond-> {response-key (mapv response-fn items)}
next-cursor (assoc :next-cursor next-cursor)))))))
(defn- indexed-reference-response
([db query-params response-key attribute target-id]
(indexed-reference-response db query-params response-key attribute target-id {}))
([db query-params response-key attribute target-id {:keys [entity-filter response-fn]
:or {entity-filter (constantly true)
response-fn block-response}}]
(let [{:keys [limit raw-cursor cursor]} (pagination-options query-params)
time-filters (time-filter-options query-params)
cursor-e (when (= "entity-id" (first cursor))
(js/parseInt (second cursor) 10))]
(cond
(nil? limit) (bad-request "invalid limit")
(and raw-cursor (or (nil? cursor) (js/isNaN cursor-e))) (bad-request "invalid cursor")
(:invalid? time-filters) (bad-request "invalid time filter")
:else
(let [datoms (cond->> (d/datoms db :avet attribute target-id)
cursor-e (drop-while #(<= (:e %) cursor-e))
true (remove #(entity-util/hidden? (d/entity db (:e %))))
true (filter #(after-time-filters? (d/entity db (:e %)) time-filters))
true (filter #(entity-filter (d/entity db (:e %)))))
selected (vec (take (inc limit) datoms))
more? (> (count selected) limit)
page (take limit selected)]
(cond-> {response-key (mapv #(response-fn (d/entity db (:e %))) page)}
more? (assoc :next-cursor
(encode-cursor ["entity-id" (str (:e (last page)))]))))))))
(defn- find-entity [db id]
(when-let [parsed (try (uuid id) (catch :default _ nil))]
(d/entity db [:block/uuid parsed])))
(defn- find-visible-entity [db id]
(when-let [entity (find-entity db id)]
(when-not (entity-util/hidden? entity) entity)))
(def ^:private page-block-pull-keys
[:db/id :block/uuid :block/title :block/order
:logseq.property/hide? :logseq.property/deleted-at
{:block/parent [:db/id :block/uuid]}
{:block/page [:db/id :block/uuid]}])
(defn- page-root-blocks [db page]
(let [page-id (:db/id page)]
(->> (d/datoms db :avet :block/page page-id)
(filter #(seq (d/datoms db :eavt (:e %) :block/parent page-id)))
(map #(:e %))
(map #(d/pull db [:db/id :block/uuid :block/title :block/order
:block/created-at :block/updated-at
:logseq.property/hide? :logseq.property/deleted-at
{:block/parent [:db/id :block/uuid]}
{:block/page [:db/id :block/uuid]}]
%))
(remove entity-util/hidden?))))
(defn- page-block-tree [db root]
(let [blocks (->> (ldb/get-block-and-children db (:block/uuid root))
(remove #(entity-util/hidden? (d/entity db (:db/id %)))))]
(some->> blocks
(map #(d/pull db page-block-pull-keys (:db/id %)))
(otree/non-consecutive-blocks->vec-tree)
first
block-response)))
(defn- paginated-page-blocks-response [db page query-params]
(let [{:keys [limit raw-cursor cursor]} (pagination-options query-params)
time-filters (time-filter-options query-params)]
(cond
(nil? limit) (bad-request "invalid limit")
(and raw-cursor (nil? cursor)) (bad-request "invalid cursor")
(:invalid? time-filters) (bad-request "invalid time filter")
:else
(let [{roots :items next-cursor :next-cursor}
(paginate (filter #(after-time-filters? % time-filters)
(page-root-blocks db page))
limit
cursor)]
(cond-> {:blocks (mapv #(page-block-tree db %) roots)}
next-cursor (assoc :next-cursor next-cursor))))))
(defn- list-pages [db]
(->> [:logseq.class/Page :logseq.class/Journal]
(keep #(d/entid db %))
(mapcat #(entities-by-avet db :block/tags %))))
(defn- page-ref-values [title]
(when (string? title)
(->> (re-seq page-ref/page-ref-re title)
(map (comp string/trim second))
(remove string/blank?)
distinct)))
(defn- integer-string? [value]
(boolean (re-matches #"-?\d+" value)))
(defn- resolve-page-ref! [conn value]
(cond
(common-util/uuid-string? value)
(when-let [entity (d/entity @conn [:block/uuid (uuid value)])]
{:block/uuid (:block/uuid entity)
:block/title value})
(integer-string? value)
(when-let [entity (d/entity @conn (js/parseInt value 10))]
{:block/uuid (:block/uuid entity)
:block/title value})
:else
(let [page (or (ldb/get-page @conn value)
(let [[_ page-id] (outliner-page/create! conn value {})]
(d/entity @conn [:block/uuid page-id])))]
{:block/uuid (:block/uuid page)
:block/title (:block/title page)})))
(defn- prepare-block-title! [conn title]
(let [refs (->> (page-ref-values title)
(keep #(resolve-page-ref! conn %))
(reduce (fn [result ref]
(assoc result (:block/uuid ref) ref)) {})
vals
vec)]
{:block/title (db-content/title-ref->id-ref title refs :replace-tag? false)
:block/refs (mapv #(vector :block/uuid (:block/uuid %)) refs)}))
(defn- tree-block [conn node]
(assoc (prepare-block-title! conn (:title node)) :block/uuid (random-uuid)))
(defn- insert-tree! [conn target nodes position]
(let [nodes (vec nodes)
blocks (mapv #(tree-block conn %) nodes)]
(when (seq blocks)
(outliner-core/insert-blocks! conn blocks target
{:sibling? false :top? (= position "prepend")
:bottom? (not= position "prepend") :keep-uuid? true})
(mapv (fn [node block]
(let [children (when (seq (:children node))
(insert-tree! conn (d/entity @conn [:block/uuid (:block/uuid block)])
(:children node) "append"))]
(cond-> {:uuid (str (:block/uuid block)) :title (:title node)}
(seq children) (assoc :children children))))
nodes blocks))))
(defn- today-journal-day []
(let [now (js/Date.)]
(+ (* (.getFullYear now) 10000) (* (inc (.getMonth now)) 100) (.getDate now))))
(defn- ensure-today-page! [conn]
(let [day (today-journal-day)]
(or (ldb/get-journal-page-by-day @conn day)
(let [formatter (:logseq.property.journal/title-format (d/entity @conn :logseq.class/Journal))
title (date-time-util/int->journal-title day formatter)
[_ page-id] (outliner-page/create! conn title {:journal? true :today-journal? true})]
(d/entity @conn [:block/uuid page-id])))))
(defn- resolve-property-ident [db value]
(or (when-let [entity (find-entity db value)] (:db/ident entity))
(when (string? value)
(let [ident (keyword value)]
(or (when (d/entity db ident) ident)
(some->> (d/datoms db :avet :block/title value)
(map #(d/entity db (:e %)))
(filter property?)
first
:db/ident))))))
(defn- resolve-property [db value]
(when-let [ident (resolve-property-ident db value)]
(let [property (d/entity db ident)]
(when-not (entity-util/hidden? property)
property))))
(def ^:private allowed-property-types
(into (set db-property-type/user-built-in-property-types)
db-property-type/user-allowed-internal-property-types))
(def ^:private allowed-cardinalities
#{:db.cardinality/one :db.cardinality/many})
(defn- valid-property-schema? [{:keys [type cardinality]}]
(and (or (nil? type)
(and (string? type) (contains? allowed-property-types (keyword type))))
(or (nil? cardinality)
(and (string? cardinality) (contains? allowed-cardinalities (keyword cardinality))))))
(def ^:private task-status-idents
{"todo" :logseq.property/status.todo
"doing" :logseq.property/status.doing
"in-review" :logseq.property/status.in-review
"done" :logseq.property/status.done
"canceled" :logseq.property/status.canceled
"backlog" :logseq.property/status.backlog})
(def ^:private task-priority-idents
{"low" :logseq.property/priority.low
"medium" :logseq.property/priority.medium
"high" :logseq.property/priority.high
"urgent" :logseq.property/priority.urgent})
(def ^:private property-choice-aliases
{:logseq.property/status task-status-idents
:logseq.property/priority task-priority-idents})
(defn- resolve-ref-entity [db property-ident value]
(let [value (if (string? value)
(get-in property-choice-aliases
[property-ident (string/lower-case value)] value)
value)
entity (cond
(number? value) (d/entity db value)
(qualified-keyword? value) (d/entity db value)
(and (string? value) (common-util/uuid-string? value))
(d/entity db [:block/uuid (uuid value)])
(string? value)
(or (d/entity db (keyword value))
(some->> (d/datoms db :avet :block/title value)
(map #(d/entity db (:e %)))
(filter #(if (= property-ident :block/tags)
(entity-util/class? %)
true))
first))
:else nil)]
(when (and entity
(or (not= property-ident :block/tags) (entity-util/class? entity)))
(:db/id entity))))
(defn- prepare-property-value [db property-ident value]
(let [property (d/entity db property-ident)]
(if-not (= :db.type/ref (:db/valueType property))
{:value value}
(let [many-input? (or (sequential? value) (set? value))
values (if many-input? value [value])
resolved (mapv #(resolve-ref-entity db property-ident %) values)]
(if (every? some? resolved)
{:value (if many-input? resolved (first resolved))}
{:error (if (= property-ident :block/tags)
"property value must resolve to an existing class by UUID, ident, or title"
"property value must resolve to an existing entity by UUID, ident, or title")})))))
(defn- set-prepared-property! [conn block-id property-ident value reset-existing-values?]
(if (coll? value)
(do
(when reset-existing-values?
(outliner-property/remove-block-property! conn block-id property-ident))
(doseq [item value]
(outliner-property/batch-set-property! conn [block-id] property-ident item {:entity-id? true})))
(outliner-property/set-block-property! conn block-id property-ident value)))
(defn- task-response [task]
(cond-> (block-response task)
(:logseq.property/status task)
(assoc :status (property-choice-response (:logseq.property/status task)))
(:logseq.property/priority task)
(assoc :priority (property-choice-response (:logseq.property/priority task)))
(:logseq.property/scheduled task) (assoc :scheduled (:logseq.property/scheduled task))
(:logseq.property/deadline task) (assoc :deadline (:logseq.property/deadline task))))
(defn- resolve-property-choice [db property-ident aliases selector]
(let [selector (get aliases selector selector)
choice-id (resolve-ref-entity db property-ident selector)
choice (when choice-id (d/entity db choice-id))
property-id (d/entid db property-ident)]
(when (and choice-id
(some #(= property-id (:v %))
(d/datoms db :eavt choice-id :block/closed-value-property)))
choice)))
(defn- search-result-response [entity]
(let [kind (block-kind entity)
type (case kind "tag" "tags" "property" "properties" "asset" "assets" "blocks")
response (case kind
"tag" (tag-response entity)
"property" (property-response entity)
"asset" (asset-response entity)
(block-response entity))]
(assoc response :resource type)))
(defn- search-results [db query types]
(let [needle (string/lower-case query)
enabled (if (seq types) (set (string/split types #",")) #{"blocks" "tags" "properties" "assets"})
title-matches (keep (fn [datom]
(try
(when (string/includes? (string/lower-case (:v datom)) needle)
(:e datom))
(catch :default _
nil)))
(d/datoms db :avet :block/title))]
(->> title-matches
(map #(d/entity db %))
(remove entity-util/hidden?)
(filter (fn [entity]
(let [kind (block-kind entity)
type (case kind "tag" "tags" "property" "properties" "asset" "assets" "blocks")]
(and (contains? enabled type)
(string/includes? (string/lower-case (or (:block/title entity) "")) needle))))))))
(defn- handle-pages [{:keys [conn db handler path-params query-params body]}]
(case handler
:semantic/pages-list
(paginated-response query-params :blocks (list-pages db) block-response)
:semantic/pages-create
(if-not (seq (:title body))
(bad-request "missing title")
(let [[title page-id] (outliner-page/create! conn (:title body) {})]
{:uuid (str page-id) :kind "page" :title title}))
:semantic/pages-blocks
(if-let [page (find-visible-entity db (:page-id path-params))]
(if-not (page? page)
(bad-request "block is not a page")
(paginated-page-blocks-response db page query-params))
(not-found))
:semantic/pages-references
(if-let [page (find-visible-entity db (:page-id path-params))]
(if (page? page)
(indexed-reference-response db query-params :references :block/refs (:db/id page))
(not-found))
(not-found))
:semantic/pages-get
(if-let [page (find-visible-entity db (:page-id path-params))]
(if (page? page) (block-response page) (not-found))
(not-found))
:semantic/pages-update
(let [page (find-entity db (:page-id path-params))]
(if (or (not (page? page)) (not (seq (:title body))))
(bad-request "invalid page-id or title")
(do
(outliner-core/save-block! conn {:block/uuid (:block/uuid page) :block/title (:title body)})
(assoc (block-response page) :title (:title body)))))
:semantic/pages-delete
(if-let [page (find-entity db (:page-id path-params))]
(if-not (page? page)
(bad-request "block is not a page")
(do (outliner-page/delete! conn (:block/uuid page)) nil))
(not-found))))
(defn- handle-blocks [{:keys [conn db handler path-params body]}]
(case handler
:semantic/blocks-get
(if-let [block (find-visible-entity db (:block-id path-params))]
(block-response block)
(not-found))
:semantic/blocks-update
(let [block (find-entity db (:block-id path-params))]
(if (or (nil? block) (not (seq (:title body))))
(bad-request "invalid block-id or title")
(do
(outliner-core/save-block! conn
(assoc (prepare-block-title! conn (:title body))
:block/uuid (:block/uuid block)))
{:uuid (str (:block/uuid block)) :kind (block-kind block) :title (:title body)})))
:semantic/blocks-delete
(if-let [block (find-entity db (:block-id path-params))]
(do
(if (page? block)
(outliner-page/delete! conn (:block/uuid block))
(outliner-core/delete-blocks! conn [block] {}))
nil)
(not-found))
:semantic/blocks-move
(let [block-ids (:block-ids body)
blocks (mapv #(find-entity db %) block-ids)
target (find-entity db (:target-id body))]
(if (or (not (seq block-ids))
(some nil? blocks) (nil? target)
(not= (count blocks) (count (distinct (map :db/id blocks))))
(some #(= (:db/id target) (:db/id %)) blocks)
(not (contains? #{"before" "after" "first-child" "last-child"} (:position body))))
(bad-request "invalid blocks, target, or position")
(let [position (:position body)
opts (case position
"before" {:sibling? true :top? true}
"after" {:sibling? true :bottom? true}
"first-child" {:sibling? false :top? true}
"last-child" {:sibling? false :bottom? true})]
(outliner-core/move-blocks! conn blocks target opts)
{:uuids (mapv #(str (:block/uuid %)) blocks) :moved true})))
:semantic/blocks-insert-children
(let [target (find-entity db (:block-id path-params))]
(if (or (nil? target) (not (contains? #{"append" "prepend"} (:position body)))
(not (seq (:blocks body))))
(bad-request "invalid target, position, or blocks")
{:blocks (insert-tree! conn target (:blocks body) (:position body))}))
:semantic/blocks-insert-tree
(let [target (find-entity db (:target-id body))]
(if (or (nil? target) (not (seq (:blocks body))))
(bad-request "invalid target or blocks")
{:blocks (insert-tree! conn target (:blocks body) (or (:position body) "append"))}))))
(defn- handle-block-properties [{:keys [conn db handler path-params body]}]
(case handler
:semantic/blocks-set-property
(let [block (find-entity db (:block-id path-params))
property-ident (resolve-property-ident db (:property-id path-params))
prepared (when property-ident (prepare-property-value db property-ident (:value body)))]
(cond
(nil? block) (bad-request "block not found")
(nil? property-ident) (bad-request "property not found")
(not (contains? body :value)) (bad-request "missing property value")
(:error prepared) (bad-request (:error prepared))
:else
(do (set-prepared-property! conn (:db/id block) property-ident (:value prepared) true)
{:updated true})))
:semantic/blocks-delete-property
(let [block (find-entity db (:block-id path-params))
property-ident (resolve-property-ident db (:property-id path-params))]
(if (or (nil? block) (nil? property-ident))
(bad-request "invalid block or property")
(do
(outliner-property/remove-block-property! conn (:db/id block) property-ident)
nil)))
:semantic/blocks-batch-set-property
(let [entries (:entries body)
resolved (mapv (fn [{:keys [block-id property-id value]}]
(let [property-ident (resolve-property-ident db property-id)]
{:block (find-entity db block-id)
:property-ident property-ident
:prepared (when property-ident
(prepare-property-value db property-ident value))})) entries)]
(if (or (not (seq entries))
(some #(or (nil? (:block %)) (nil? (:property-ident %))
(:error (:prepared %))) resolved))
(bad-request "invalid batch property entry")
(do (ldb/batch-transact-with-temp-conn!
conn {:outliner-op :batch-set-property}
(fn [temp-conn]
(doseq [{:keys [block property-ident prepared]} resolved]
(set-prepared-property! temp-conn (:db/id block) property-ident (:value prepared)
(true? (:isResetExistingValues body))))))
{:updated (count resolved)})))
:semantic/blocks-batch-delete-property
(let [entries (:entries body)
resolved (mapv (fn [{:keys [block-id property-id]}]
{:block (find-entity db block-id)
:property-ident (resolve-property-ident db property-id)}) entries)]
(if (or (not (seq entries)) (some #(or (nil? (:block %)) (nil? (:property-ident %))) resolved))
(bad-request "invalid batch property entry")
(do
(ldb/batch-transact-with-temp-conn!
conn {:outliner-op :batch-remove-property}
(fn [temp-conn]
(doseq [{:keys [block property-ident]} resolved]
(outliner-property/remove-block-property! temp-conn (:db/id block) property-ident))))
{:deleted (count resolved)})))))
(defn- handle-capture-and-tags [{:keys [conn db handler path-params query-params body]}]
(case handler
:semantic/capture
(if-not (seq (:blocks body))
(bad-request "missing blocks")
(let [today (ensure-today-page! conn)
inserted (insert-tree! conn today (:blocks body) "append")]
{:page-id (str (:block/uuid today)) :blocks inserted}))
:semantic/tags-list
(paginated-response query-params :tags (entities-by-avet db :block/tags :logseq.class/Tag) tag-response)
:semantic/tags-create
(if-not (seq (:title body))
(bad-request "missing title")
(let [[title tag-id] (outliner-page/create! conn (:title body) {:class? true})]
{:uuid (str tag-id) :title title}))
:semantic/tags-get
(if-let [tag (find-visible-entity db (:tag-id path-params))]
(if (tag? tag) (tag-response tag) (not-found))
(not-found))
:semantic/tags-objects
(if-let [tag (find-visible-entity db (:tag-id path-params))]
(if (tag? tag)
(indexed-reference-response db query-params :objects :block/tags (:db/id tag))
(not-found))
(not-found))
:semantic/tags-update
(let [tag (find-entity db (:tag-id path-params))]
(if (or (not (tag? tag)) (not (seq (:title body))))
(bad-request "invalid tag-id or title")
(do
(outliner-core/save-block! conn {:block/uuid (:block/uuid tag) :block/title (:title body)})
(assoc (tag-response tag) :title (:title body)))))
:semantic/tags-delete
(if-let [tag (find-entity db (:tag-id path-params))]
(if-not (tag? tag)
(not-found)
(do (outliner-page/delete! conn (:block/uuid tag)) nil))
(not-found))))
(defn- handle-tasks [{:keys [conn db handler query-params body]}]
(case handler
:semantic/tasks-list
(let [status (:status query-params)
priority (:priority query-params)
content (some-> (:content query-params) string/lower-case)
status-choice (when status (resolve-property-choice db :logseq.property/status
task-status-idents status))
priority-choice (when priority (resolve-property-choice db :logseq.property/priority
task-priority-idents priority))]
(cond
(and status (nil? status-choice)) (bad-request "invalid task status")
(and priority (nil? priority-choice)) (bad-request "invalid task priority")
:else
(indexed-reference-response
db query-params :tasks :block/tags :logseq.class/Task
{:entity-filter
(fn [entity]
(and (or (nil? status-choice)
(= (:db/id status-choice) (:db/id (:logseq.property/status entity))))
(or (nil? priority-choice)
(= (:db/id priority-choice) (:db/id (:logseq.property/priority entity))))
(or (nil? content)
(string/includes? (string/lower-case (or (:block/title entity) "")) content))))
:response-fn task-response})))
:semantic/tasks-create
(let [status (or (:status body) "todo")
priority (:priority body)
status-choice (resolve-property-choice db :logseq.property/status task-status-idents status)
priority-choice (when priority
(resolve-property-choice db :logseq.property/priority
task-priority-idents priority))
target (when-let [page-id (:page-id body)]
(find-entity db page-id))]
(cond
(not (seq (:title body))) (bad-request "missing task title")
(nil? status-choice) (bad-request "invalid task status")
(and priority (nil? priority-choice)) (bad-request "invalid task priority")
(and (:page-id body) (not (page? target))) (bad-request "invalid task page-id")
:else
(let [task-id (atom nil)]
(ldb/batch-transact-with-temp-conn!
conn {:outliner-op :create-task}
(fn [temp-conn]
(let [target (if target
(d/entity @temp-conn [:block/uuid (:block/uuid target)])
(ensure-today-page! temp-conn))
inserted (first (insert-tree! temp-conn target [{:title (:title body)}] "append"))
task-uuid (uuid (:uuid inserted))
task (d/entity @temp-conn [:block/uuid task-uuid])]
(reset! task-id task-uuid)
(outliner-property/set-block-property! temp-conn (:db/id task) :block/tags
(:db/id (d/entity @temp-conn :logseq.class/Task)))
(outliner-property/set-block-property! temp-conn (:db/id task) :logseq.property/status
(:db/id status-choice))
(when priority-choice
(outliner-property/set-block-property! temp-conn (:db/id task) :logseq.property/priority
(:db/id priority-choice)))
(doseq [property-ident [:logseq.property/scheduled :logseq.property/deadline]
:let [value (get body (keyword (name property-ident)))]
:when (some? value)]
(outliner-property/set-block-property! temp-conn (:db/id task) property-ident value)))))
(task-response (d/entity @conn [:block/uuid @task-id])))))))
(defn- handle-assets
[{:keys [repo conn db handler path-params query-params body]}]
(case handler
:semantic/assets-list
(paginated-response query-params :assets
(entities-by-avet db :logseq.property.asset/type)
asset-response)
:semantic/assets-create
(let [{:keys [file-name size checksum encoding page-id title]} query-params
size (when (and size (re-matches #"\d+" (str size))) (js/parseInt size 10))
checksum (some-> checksum string/lower-case)
asset-type (when (string? file-name) (db-asset/asset-path->type file-name))
target (when page-id (find-entity db page-id))]
(cond
(or (not (seq file-name)) (not (seq asset-type))) (bad-request "invalid asset file-name")
(nil? size) (bad-request "invalid asset size")
(> size max-asset-size) (api-error! "asset too large" 413)
(not (and (string? checksum) (re-matches #"[0-9a-f]{64}" checksum)))
(bad-request "invalid asset checksum")
(and encoding (not= "base64" encoding)) (bad-request "invalid asset encoding")
(and page-id (not (page? target))) (bad-request "invalid asset page-id")
(seq (d/datoms db :avet :logseq.property.asset/checksum checksum))
(api-error! "asset checksum already exists" 409)
:else
(let [asset-id (random-uuid)
file-name* (str asset-id "." asset-type)
title (or title (db-asset/asset-name->title file-name))
payload (if (= "base64" encoding)
(js/Buffer.from (str body) "base64")
body)]
(platform/asset-write-bytes! (platform/current) repo file-name* payload)
(let [target (or target (ensure-today-page! conn))
block {:block/uuid asset-id
:block/title title
:block/tags #{:logseq.class/Asset}
:logseq.property.asset/type asset-type
:logseq.property.asset/size size
:logseq.property.asset/checksum checksum}]
(outliner-core/insert-blocks! conn [block] target
{:sibling? false :bottom? true :keep-uuid? true})
(asset-response (d/entity @conn [:block/uuid asset-id]))))))
:semantic/assets-get
(if-let [asset (find-visible-entity db (:asset-block-id path-params))]
(if-not (asset? asset)
(bad-request "block is not an asset")
(assoc (asset-response asset)
:url (str "/v1/assets/" (js/encodeURIComponent repo) "/"
(js/encodeURIComponent (asset-file-name asset)))))
(not-found))))
(defn- handle-properties-and-search
[{:keys [conn db handler path-params query-params body]}]
(case handler
:semantic/properties-list
(paginated-response query-params :properties (->> (db-db/get-all-properties db)
(remove entity-util/hidden?))
property-response)
:semantic/properties-create
(if (or (not (seq (:title body))) (not (valid-property-schema? body)))
(bad-request "invalid property schema")
(let [property (outliner-property/upsert-property!
conn nil
{:logseq.property/type (keyword (or (:type body) "default"))
:db/cardinality (keyword (or (:cardinality body) "db.cardinality/one"))}
{:property-name (:title body)})]
(property-response property)))
:semantic/properties-get
(if-let [property (resolve-property db (:property-id path-params))]
(if (property? property) (property-response property) (not-found))
(not-found))
:semantic/properties-update
(let [property (resolve-property db (:property-id path-params))]
(if (or (not (property? property)) (empty? body) (not (valid-property-schema? body)))
(bad-request "invalid property-id or update")
(let [schema (cond-> {}
(:type body) (assoc :logseq.property/type (keyword (:type body)))
(:cardinality body) (assoc :db/cardinality (keyword (:cardinality body))))
updated (outliner-property/upsert-property!
conn (:db/ident property) schema
(cond-> {} (:title body) (assoc :property-name (:title body))))]
(property-response updated))))
:semantic/properties-delete
(if-let [property (resolve-property db (:property-id path-params))]
(if-not (property? property)
(not-found)
(if (outliner-page/delete! conn (:block/uuid property))
nil
(bad-request "property cannot be deleted")))
(not-found))
:semantic/search
(let [query (:q query-params)]
(if-not (seq query)
(bad-request "missing q")
(paginated-response query-params :results
(search-results db query (:types query-params))
search-result-response
paginate-bounded)))))
(def ^:private page-handlers
#{:semantic/pages-list :semantic/pages-create :semantic/pages-blocks :semantic/pages-references :semantic/pages-get
:semantic/pages-update :semantic/pages-delete})
(def ^:private block-handlers
#{:semantic/blocks-get :semantic/blocks-update :semantic/blocks-delete :semantic/blocks-move
:semantic/blocks-insert-children :semantic/blocks-insert-tree})
(def ^:private block-property-handlers
#{:semantic/blocks-set-property :semantic/blocks-delete-property
:semantic/blocks-batch-set-property :semantic/blocks-batch-delete-property})
(def ^:private capture-and-tag-handlers
#{:semantic/capture :semantic/tags-list :semantic/tags-create :semantic/tags-get :semantic/tags-objects
:semantic/tags-update :semantic/tags-delete})
(def ^:private task-handlers
#{:semantic/tasks-list :semantic/tasks-create})
(def ^:private asset-handlers
#{:semantic/assets-list :semantic/assets-create :semantic/assets-get})
(def ^:private property-and-search-handlers
#{:semantic/properties-list :semantic/properties-create :semantic/properties-get
:semantic/properties-update :semantic/properties-delete
:semantic/search})
(defn- dispatch [{:keys [handler] :as context}]
(cond
(contains? page-handlers handler) (handle-pages context)
(contains? block-handlers handler) (handle-blocks context)
(contains? block-property-handlers handler) (handle-block-properties context)
(contains? capture-and-tag-handlers handler) (handle-capture-and-tags context)
(contains? task-handlers handler) (handle-tasks context)
(contains? asset-handlers handler) (handle-assets context)
(contains? property-and-search-handlers handler) (handle-properties-and-search context)
:else (not-found)))
(def-thread-api :thread-api/semantic-request
[repo handler path-params query-params body]
(if-let [conn (worker-state/get-datascript-conn repo)]
(dispatch {:repo repo
:conn conn
:db @conn
:handler handler
:path-params (or path-params {})
:query-params (or query-params {})
:body body})
(throw (api-error 404 :repo-not-found "repo not found"))))

View File

@@ -0,0 +1,397 @@
(ns frontend.worker.semantic-routes
"Route table and OpenAPI document for the semantic API served by the
daemon (Fase 9). Ported from deps/db-sync/.../routes/semantic.cljs: that
version routes by :graph-id under a Cloudflare Worker Durable Object per
graph. This daemon is bound to a single repo per process (see
db_worker_node.cljs's `bound-repo`), so the :graph-id path segment and the
listGraphs operation are dropped -- every request implicitly targets the
daemon's own bound repo. OAuth2 is likewise dropped in favor of the
daemon's actual bearer-token session auth (see db-worker-node-auth)."
(:require [clojure.string :as string]
[flatland.ordered.map :refer [ordered-map]]
[reitit.core :as r]))
(def operations
[{:method "GET" :path "/v1/semantic/pages"
:handler :semantic/pages-list :operation-id "listPages" :success-status 200}
{:method "POST" :path "/v1/semantic/pages"
:handler :semantic/pages-create :operation-id "createPage" :success-status 201}
{:method "GET" :path "/v1/semantic/pages/:page-id/blocks"
:handler :semantic/pages-blocks :operation-id "listPageBlocks" :success-status 200}
{:method "GET" :path "/v1/semantic/pages/:page-id/references"
:handler :semantic/pages-references :operation-id "listPageReferences" :success-status 200}
{:method "DELETE" :path "/v1/semantic/pages/:page-id"
:handler :semantic/pages-delete :operation-id "deletePage" :success-status 204}
{:method "GET" :path "/v1/semantic/pages/:page-id"
:handler :semantic/pages-get :operation-id "getPage" :success-status 200}
{:method "PATCH" :path "/v1/semantic/pages/:page-id"
:handler :semantic/pages-update :operation-id "updatePage" :success-status 200}
{:method "GET" :path "/v1/semantic/blocks/:block-id"
:handler :semantic/blocks-get :operation-id "getBlock" :success-status 200}
{:method "PATCH" :path "/v1/semantic/blocks/:block-id"
:handler :semantic/blocks-update :operation-id "updateBlock" :success-status 200}
{:method "DELETE" :path "/v1/semantic/blocks/:block-id"
:handler :semantic/blocks-delete :operation-id "deleteBlock" :success-status 204}
{:method "POST" :path "/v1/semantic/block-moves"
:handler :semantic/blocks-move :operation-id "moveBlocks" :success-status 200}
{:method "POST" :path "/v1/semantic/blocks/:block-id/children"
:handler :semantic/blocks-insert-children :operation-id "insertBlockChildren" :success-status 201}
{:method "POST" :path "/v1/semantic/block-trees"
:handler :semantic/blocks-insert-tree :operation-id "insertBlockTree" :success-status 201}
{:method "PUT" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
:handler :semantic/blocks-set-property :operation-id "setBlockProperty" :success-status 200}
{:method "DELETE" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
:handler :semantic/blocks-delete-property :operation-id "deleteBlockProperty" :success-status 204}
{:method "POST" :path "/v1/semantic/block-properties/batch-set"
:handler :semantic/blocks-batch-set-property :operation-id "batchSetBlockProperty" :success-status 200}
{:method "POST" :path "/v1/semantic/block-properties/batch-delete"
:handler :semantic/blocks-batch-delete-property :operation-id "batchDeleteBlockProperty" :success-status 200}
{:method "POST" :path "/v1/semantic/capture"
:handler :semantic/capture :operation-id "captureToToday" :success-status 201}
{:method "GET" :path "/v1/semantic/tasks"
:handler :semantic/tasks-list :operation-id "listTasks" :success-status 200}
{:method "POST" :path "/v1/semantic/tasks"
:handler :semantic/tasks-create :operation-id "createTask" :success-status 201}
{:method "GET" :path "/v1/semantic/tags"
:handler :semantic/tags-list :operation-id "listTags" :success-status 200}
{:method "POST" :path "/v1/semantic/tags"
:handler :semantic/tags-create :operation-id "createTag" :success-status 201}
{:method "GET" :path "/v1/semantic/tags/:tag-id"
:handler :semantic/tags-get :operation-id "getTag" :success-status 200}
{:method "GET" :path "/v1/semantic/tags/:tag-id/objects"
:handler :semantic/tags-objects :operation-id "listTagObjects" :success-status 200}
{:method "PATCH" :path "/v1/semantic/tags/:tag-id"
:handler :semantic/tags-update :operation-id "updateTag" :success-status 200}
{:method "DELETE" :path "/v1/semantic/tags/:tag-id"
:handler :semantic/tags-delete :operation-id "deleteTag" :success-status 204}
{:method "GET" :path "/v1/semantic/properties"
:handler :semantic/properties-list :operation-id "listProperties" :success-status 200}
{:method "POST" :path "/v1/semantic/properties"
:handler :semantic/properties-create :operation-id "createProperty" :success-status 201}
{:method "GET" :path "/v1/semantic/properties/:property-id"
:handler :semantic/properties-get :operation-id "getProperty" :success-status 200}
{:method "PATCH" :path "/v1/semantic/properties/:property-id"
:handler :semantic/properties-update :operation-id "updateProperty" :success-status 200}
{:method "DELETE" :path "/v1/semantic/properties/:property-id"
:handler :semantic/properties-delete :operation-id "deleteProperty" :success-status 204}
{:method "GET" :path "/v1/semantic/assets"
:handler :semantic/assets-list :operation-id "listAssets" :success-status 200}
{:method "POST" :path "/v1/semantic/assets"
:handler :semantic/assets-create :operation-id "createAsset" :success-status 201}
{:method "GET" :path "/v1/semantic/assets/:asset-block-id"
:handler :semantic/assets-get :operation-id "getAsset" :success-status 200}
{:method "GET" :path "/v1/semantic/search"
:handler :semantic/search :operation-id "searchGraph" :success-status 200}])
(def ^:private operation-docs
{"listPages" ["List pages" "Returns a cursor-paginated list of page blocks in the graph."]
"createPage" ["Create a page" "Creates a page block with the supplied title."]
"listPageBlocks" ["List a page's blocks" "Returns a cursor-paginated list of the page's top-level blocks, including each selected block's descendant tree."]
"listPageReferences" ["List references to a page" "Returns a cursor-paginated flat list of blocks and pages that reference the addressed page. Results are not recursively expanded."]
"getPage" ["Get a page" "Returns one page block by UUID without loading its block tree."]
"updatePage" ["Update a page" "Renames one page identified by its block UUID."]
"deletePage" ["Delete a page" "Deletes one page through Logseq's page deletion rules, including its block tree."]
"getBlock" ["Get a block" "Returns one block or page by UUID without loading an unbounded tree."]
"updateBlock" ["Update a block" "Replaces the title of one block identified by UUID."]
"deleteBlock" ["Delete a block" "Deletes one block and its descendants. Asset blocks are deleted through this same operation."]
"moveBlocks" ["Move blocks" "Moves one or more blocks together before, after, into the first-child position, or into the last-child position of a target block. Logseq preserves the blocks' outliner order."]
"insertBlockChildren" ["Insert child block trees" "Appends or prepends one or more recursive block trees as children of the addressed block."]
"insertBlockTree" ["Insert block trees" "Inserts one or more recursive block trees relative to an explicit target block."]
"setBlockProperty" ["Set a block property" "Idempotently adds or replaces one typed property value on one block in a DB graph. The property path value may be its UUID or ident. Never write legacy file-graph key:: value syntax into the block title."]
"deleteBlockProperty" ["Delete a block property" "Idempotently removes one property from one block. The property definition itself is not deleted."]
"batchSetBlockProperty" ["Set block properties in a batch" "Atomically adds or replaces multiple typed block-property values in a DB graph after validating every entry. Property IDs are UUIDs or idents; never use legacy file-graph key:: value syntax."]
"batchDeleteBlockProperty" ["Delete block properties in a batch" "Atomically removes multiple block-property values after validating every entry."]
"captureToToday" ["Capture to today's journal" "Creates today's journal page when necessary and appends the supplied recursive block trees to its end."]
"listTasks" ["List DB tasks" "Lists cursor-paginated DB Task objects using the Task class index, with optional status, priority, and title-content filters. Use this operation instead of graph search to retrieve tasks."]
"createTask" ["Create a DB task" "Creates a DB Task object with the Task class and typed status, priority, scheduled, and deadline properties. Never encode a task as Markdown TODO text."]
"listTags" ["List tags" "Returns a cursor-paginated list of tag entities in the graph."]
"createTag" ["Create a tag" "Creates a Logseq tag with the supplied title."]
"getTag" ["Get a tag" "Returns one tag by UUID."]
"listTagObjects" ["List objects with a tag" "Returns a cursor-paginated flat list of blocks and pages tagged with the addressed tag. Results are not recursively expanded."]
"updateTag" ["Update a tag" "Renames one tag identified by UUID."]
"deleteTag" ["Delete a tag" "Deletes one tag through Logseq's page deletion rules."]
"listProperties" ["List property definitions" "Returns a cursor-paginated list of property definitions in the graph."]
"createProperty" ["Create a property definition" "Creates a typed property definition in a DB graph with its name, value type, and cardinality. Use the returned UUID or ident to set values; never use legacy file-graph key:: value syntax."]
"getProperty" ["Get a property definition" "Returns one property definition by UUID."]
"updateProperty" ["Update a property definition" "Updates the name, value type, or cardinality of one typed property definition in a DB graph. Never use legacy file-graph key:: value syntax."]
"deleteProperty" ["Delete a property definition" "Deletes one property definition through Logseq's property deletion rules."]
"listAssets" ["List assets" "Returns a cursor-paginated list of asset blocks with optional creation and update time filters."]
"createAsset" ["Upload an asset" "Streams a file of at most 100MB to the daemon's local asset storage and creates its Asset-class block. Requires a client-computed SHA-256 checksum."]
"getAsset" ["Get an asset" "Returns asset metadata and a server-relative URL for the asset. Resolve the URL against the daemon's own base URL; it requires the same bearer token as every other route (or a ?token= query fallback for direct navigation)."]
"searchGraph" ["Search graph resources" "Searches blocks, tags, properties, and assets by title and returns cursor-paginated results."]})
(defn- operation-routes []
(->> operations
(group-by :path)
(mapv (fn [[path path-operations]]
[path {:methods (into {} (map (juxt :method identity)) path-operations)}]))))
(def ^:private router
(r/router (operation-routes)))
(defn match [method path]
(when-let [route (r/match-by-path router path)]
(when-let [operation (get-in route [:data :methods method])]
(merge operation (select-keys route [:path-params])))))
(defn- openapi-path [path]
(string/replace path #":([^/]+)" "{$1}"))
(defn- path-parameters [path]
(mapv (fn [[_ parameter]]
(cond-> {:name parameter :in "path" :required true :schema {:type "string"}}
(= "property-id" parameter)
(assoc :description "Property UUID, qualified ident, or exact title.")))
(re-seq #":([^/]+)" path)))
(def ^:private pagination-parameters
[{:name "limit" :in "query" :schema {:type "integer" :minimum 1 :maximum 200 :default 50}}
{:name "cursor" :in "query" :schema {:type "string"}}])
(def ^:private time-filter-parameters
[{:name "created-after" :in "query"
:description "Return entities created strictly after this Unix epoch timestamp in milliseconds."
:schema {:type "integer" :minimum 0}}
{:name "updated-after" :in "query"
:description "Return entities updated strictly after this Unix epoch timestamp in milliseconds."
:schema {:type "integer" :minimum 0}}])
(def ^:private asset-upload-parameters
[{:name "file-name" :in "query" :required true :schema {:type "string"}}
{:name "size" :in "query" :required true
:description "Exact decoded file size in bytes. Uploads larger than 100MB are rejected."
:schema {:type "integer" :minimum 0 :maximum 104857600}}
{:name "title" :in "query" :schema {:type "string"}}
{:name "page-id" :in "query"
:description "Destination page UUID. Omit to append to today's journal."
:schema {:type "string"}}
{:name "checksum" :in "query" :required true
:description "Lowercase or uppercase SHA-256 hex digest of the file."
:schema {:type "string" :pattern "^[0-9a-fA-F]{64}$"}}
{:name "encoding" :in "query"
:description "Use base64 when the caller cannot transfer raw binary. The server decodes it as a stream before writing to disk; size and checksum describe the decoded file."
:schema {:type "string" :enum ["base64"]}}])
(defn- request-schema [operation-id]
(case operation-id
"createPage" {:required ["title"] :properties {:title {:type "string"}}}
"updatePage" {:required ["title"] :properties {:title {:type "string"}}}
"updateBlock" {:required ["title"] :properties {:title {:type "string"}}}
"moveBlocks" {:required ["block-ids" "target-id" "position"]
:properties {:block-ids {:type "array" :minItems 1 :uniqueItems true
:items {:type "string"}}
:target-id {:type "string"}
:position {:enum ["before" "after" "first-child" "last-child"]}}}
"insertBlockChildren" {:required ["position" "blocks"]
:properties {:position {:enum ["append" "prepend"]}
:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
"insertBlockTree" {:required ["target-id" "blocks"]
:properties {:target-id {:type "string"}
:position {:enum ["append" "prepend"] :default "append"}
:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
"setBlockProperty" {:required ["value"]
:properties {:value {:$ref "#/components/schemas/PropertyValue"}}}
"batchSetBlockProperty" {:required ["entries"]
:properties {:isResetExistingValues
{:type "boolean" :default false
:description "For cardinality-many values, replace existing values when true; append when false or omitted."}
:entries {:type "array"
:items {:type "object" :required ["block-id" "property-id" "value"]
:properties {:block-id {:type "string"}
:property-id {:type "string"}
:value {:$ref "#/components/schemas/PropertyValue"}}}}}}
"batchDeleteBlockProperty" {:required ["entries"]
:properties {:entries {:type "array"
:items {:type "object" :required ["block-id" "property-id"]
:properties {:block-id {:type "string"}
:property-id {:type "string"}}}}}}
"captureToToday" {:required ["blocks"]
:properties {:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
"createTask" {:required ["title"]
:properties {:title {:type "string"}
:page-id {:type "string"
:description "Destination page UUID. Omit to append to today's journal."}
:status {:$ref "#/components/schemas/TaskStatusSelector"}
:priority {:$ref "#/components/schemas/TaskPrioritySelector"}
:scheduled {:type "integer" :description "Scheduled date as YYYYMMDD."}
:deadline {:type "integer" :description "Deadline date as YYYYMMDD."}}}
"createTag" {:required ["title"] :properties {:title {:type "string"}}}
"updateTag" {:required ["title"] :properties {:title {:type "string"}}}
"createProperty" {:required ["title"]
:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"}
:cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}}
"updateProperty" {:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"}
:cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}}
nil))
(defn- operation-parameters [{:keys [operation-id path]}]
(cond-> (path-parameters path)
(contains? #{"listPages" "listPageBlocks" "listPageReferences" "listTasks" "listTags"
"listTagObjects" "listProperties" "listAssets" "searchGraph"} operation-id)
(into pagination-parameters)
(contains? #{"listPages" "listTasks" "listTags" "listTagObjects" "listProperties" "listAssets" "searchGraph"}
operation-id)
(into time-filter-parameters)
(= "createAsset" operation-id)
(into asset-upload-parameters)
(= "searchGraph" operation-id)
(into [{:name "q" :in "query" :required true :schema {:type "string"}}
{:name "types" :in "query" :schema {:type "string"}}])
(= "listTasks" operation-id)
(into [{:name "status" :in "query" :schema {:$ref "#/components/schemas/TaskStatusSelector"}}
{:name "priority" :in "query" :schema {:$ref "#/components/schemas/TaskPrioritySelector"}}
{:name "content" :in "query" :schema {:type "string"}
:description "Case-insensitive task title filter."}])))
(defn- request-body [operation-id]
(if (= "createAsset" operation-id)
{:required true
:content {"application/octet-stream"
{:schema {:type "string" :format "binary"}}}}
(when-let [schema (request-schema operation-id)]
{:required true
:content {"application/json" {:schema (assoc schema :type "object")}}})))
(defn- list-response [response-key schema description]
{:description description
:content {"application/json"
{:schema {:type "object"
:required [(name response-key)]
:properties {response-key {:type "array"
:items {:$ref (str "#/components/schemas/" schema)}}
:next-cursor {:type "string"}}}}}})
(defn- entity-response [schema description]
{:description description
:content {"application/json" {:schema {:$ref (str "#/components/schemas/" schema)}}}})
(defn- operation-responses [operation-id]
(merge {"200" (case operation-id
"listPages" (list-response :pages "PageResponse" "A cursor page of pages")
"listPageBlocks"
(list-response :blocks "BlockResponse" "A cursor page of top-level block trees")
"listPageReferences" (list-response :references "BlockResponse" "A cursor page of page references")
"listTasks"
(list-response :tasks "TaskResponse" "A cursor page of DB Task objects")
"listTags" (list-response :tags "TagResponse" "A cursor page of tags")
"listTagObjects" (list-response :objects "BlockResponse" "A cursor page of tagged objects")
"listProperties" (list-response :properties "PropertyResponse" "A cursor page of property definitions")
"listAssets"
(list-response :assets "AssetResponse" "A cursor page of asset blocks")
"searchGraph" (list-response :results "SearchResultResponse" "A cursor page of search results")
"getPage" (entity-response "PageResponse" "A page")
"updatePage" (entity-response "PageResponse" "The updated page")
"getBlock" (entity-response "BlockResponse" "A block")
"updateBlock" (entity-response "BlockResponse" "The updated block")
"getTag" (entity-response "TagResponse" "A tag")
"updateTag" (entity-response "TagResponse" "The updated tag")
"getProperty"
(entity-response "PropertyResponse" "A property definition and its user-extensible choices")
"updateProperty" (entity-response "PropertyResponse" "The updated property definition")
"getAsset" (entity-response "AssetResponse" "Asset metadata and a server-relative URL")
{:description "Success"})
"400" {:description "Invalid request"}
"401" {:description "Missing or invalid bearer token"}
"404" {:description "Not found"}}
(case operation-id
"createPage" {"201" (entity-response "PageResponse" "The created page")}
"insertBlockChildren" {"201" {:description "The inserted block trees"}}
"insertBlockTree" {"201" {:description "The inserted block trees"}}
"createTag" {"201" (entity-response "TagResponse" "The created tag")}
"createProperty" {"201" (entity-response "PropertyResponse" "The created property definition")}
"createTask" {"201" (entity-response "TaskResponse" "The created DB Task object")}
"createAsset" {"201" (entity-response "AssetResponse" "The created asset block")}
{})))
(defn openapi-document
[{:keys [repo]}]
{:openapi "3.1.0"
:info {:title "Logseq Daemon Semantic API" :version "1.0.0"
:description (str "Operates on the graph bound to this daemon instance"
(when repo (str " (" repo ")")) ".")}
:servers [{:url "/"}]
:components
{:schemas (ordered-map
:BlockTree {:type "object" :required ["title"]
:properties {:title {:type "string"}
:children {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
:BlockResponse {:type "object" :required ["uuid" "kind" "title"]
:properties {:uuid {:type "string"}
:kind {:type "string"}
:title {:type "string"}
:order {:type "string"}
:parent-id {:type "string"}
:page-id {:type "string"}
:children {:type "array"
:items {:$ref "#/components/schemas/BlockResponse"}}}}
:PageResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}]}
:TagResponse {:type "object" :required ["uuid" "title"]
:properties {:uuid {:type "string"}
:title {:type "string"}
:ident {:type "string"}}}
:SearchResultResponse {:type "object" :required ["uuid" "title" "resource"]
:properties {:uuid {:type "string"}
:title {:type "string"}
:resource {:enum ["blocks" "tags" "properties" "assets"]}}}
:EntitySelector {:type "string"
:description "An existing entity UUID, qualified ident, or exact title."}
:TaskStatusSelector
{:description "A built-in status alias or any user-extensible Status property choice selected by UUID, ident, or exact title."
:anyOf [{:type "string" :enum ["todo" "doing" "in-review" "done" "canceled" "backlog"]}
{:$ref "#/components/schemas/EntitySelector"}]}
:TaskPrioritySelector
{:description "A built-in priority alias or any user-extensible Priority property choice selected by UUID, ident, or exact title."
:anyOf [{:type "string" :enum ["low" "medium" "high" "urgent"]}
{:$ref "#/components/schemas/EntitySelector"}]}
:PropertyChoice {:type "object" :required ["uuid" "title"]
:properties {:uuid {:type "string"}
:ident {:type "string"}
:title {:type "string"}
:value {}}}
:PropertyResponse {:type "object" :required ["uuid" "title" "type" "cardinality"]
:properties {:uuid {:type "string"}
:ident {:type "string"}
:title {:type "string"}
:type {:type "string"}
:cardinality {:type "string"}
:choices {:type "array"
:description "The current user-extensible choices for this property."
:items {:$ref "#/components/schemas/PropertyChoice"}}}}
:PropertyType {:type "string"
:enum ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset"
"map" "json" "string"]}
:AssetResponse {:type "object" :required ["uuid" "title" "type" "size" "checksum"]
:properties {:uuid {:type "string"}
:title {:type "string"}
:type {:type "string"}
:size {:type "integer" :minimum 0 :maximum 104857600}
:checksum {:type "string" :pattern "^[0-9a-f]{64}$"}
:url {:type "string"}}}
:TaskResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}
{:type "object"
:required ["status"]
:properties {:status {:$ref "#/components/schemas/PropertyChoice"}
:priority {:$ref "#/components/schemas/PropertyChoice"}
:scheduled {:type "integer"}
:deadline {:type "integer"}}}]}
:PropertyValue
{:description "For ref properties, pass an existing entity by UUID, ident, or title. Status and Priority also accept case-insensitive built-in aliases such as TODO, DONE, and HIGH; user-defined choices use UUID, ident, or exact title. Class properties such as Tags accept a class UUID, ident, or title, or an array of those selectors. Scalar properties accept their JSON scalar value."
:oneOf [{:type "string"} {:type "number"} {:type "boolean"}
{:type "array" :items {:oneOf [{:type "string"} {:type "number"}]}}]})
:securitySchemes
{:bearerAuth {:type "http" :scheme "bearer"
:description "Session token from POST /v1/auth/login."}}}
:security [{:bearerAuth []}]
:paths
(reduce (fn [paths {:keys [method path operation-id]}]
(let [[summary description] (get operation-docs operation-id)]
(assoc-in paths [(openapi-path path) (keyword (.toLowerCase method))]
(cond-> {:operationId operation-id
:summary summary
:description description
:parameters (operation-parameters {:operation-id operation-id :path path})
:responses (operation-responses operation-id)}
(request-body operation-id)
(assoc :requestBody (request-body operation-id))))))
(ordered-map)
operations)})

View File

@@ -0,0 +1,222 @@
(ns frontend.worker.semantic-test
"Exercises the semantic API (Fase 9) over real HTTP against a live daemon,
the same way db_worker_node_test.cljs exercises /v1/invoke -- these
operations were ported from deps/db-sync/.../handler/semantic.cljs onto
frontend.worker.handler.semantic, re-hosted on the daemon's own
authoritative conn instead of a Cloudflare Durable Object's SQLite."
(:require ["http" :as http]
[cljs.test :refer [async deftest is]]
[frontend.test.node-helper :as node-helper]
[frontend.worker.db-worker-node :as db-worker-node]))
;; Same literal credential db_worker_node_test.cljs bootstraps with -- both
;; namespaces get `require`d into the same test.js bundle, and this is a
;; process-wide env var, so setting it to a different value here would win
;; the race against whichever namespace loads later and lock the other
;; file's daemons out of their own bootstrapped credential.
(def ^:private test-daemon-username "db-worker-node-test-user")
(def ^:private test-daemon-password "db-worker-node-test-password")
(aset (.-env js/process) "LOGSEQ_DAEMON_USERNAME" test-daemon-username)
(aset (.-env js/process) "LOGSEQ_DAEMON_PASSWORD" test-daemon-password)
(defn- http-request
[opts body]
(js/Promise.
(fn [resolve reject]
(let [req (.request http (clj->js opts)
(fn [^js res]
(let [chunks (array)]
(.on res "data" (fn [chunk] (.push chunks chunk)))
(.on res "end" (fn []
(resolve {:status (.-statusCode res)
:body (.toString (js/Buffer.concat chunks) "utf8")}))))))]
(.on req "error" reject)
(when body (.write req body))
(.end req)))))
(defn- json-body [{:keys [body]}]
(when-not (= "" body)
(js->clj (js/JSON.parse body) :keywordize-keys true)))
(defn- auth-headers!
[host port]
(.then (http-request {:hostname host
:port port
:path "/v1/auth/login"
:method "POST"
:headers {"Content-Type" "application/json"}}
(js/JSON.stringify
(clj->js {:username test-daemon-username
:password test-daemon-password
:deviceLabel "test"})))
(fn [result]
{"Content-Type" "application/json"
"Authorization" (str "Bearer " (:token (json-body result)))})))
(defn- semantic-request
([host port method path] (semantic-request host port method path nil))
([host port method path body]
(.then (auth-headers! host port)
(fn [headers]
(http-request {:hostname host
:port port
:path path
:method method
:headers headers}
(when body (js/JSON.stringify (clj->js body))))))))
(defn- start-daemon!
[opts]
(db-worker-node/start-daemon! (update opts :log-level #(or % "error"))))
(defn- with-daemon!
"Starts a fresh daemon over a fresh repo, runs (f host port), and stops the
daemon afterwards regardless of outcome. Returns a promise for `async`."
[f]
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "semantic-api")
repo (str "logseq_db_semantic_" (subs (str (random-uuid)) 0 8))]
(-> (.then (start-daemon! {:root-dir data-dir :repo repo})
(fn [{:keys [host port stop!]}]
(reset! daemon {:stop! stop!})
(f host port)))
(.catch (fn [e]
(println "[semantic-test] error:" e)
(is false (str e))))
(.finally (fn []
(when-let [stop! (:stop! @daemon)]
(stop!)))))))
(deftest semantic-pages-create-get-list-and-delete
(async done
(-> (with-daemon!
(fn [host port]
(-> (semantic-request host port "POST" "/v1/semantic/pages" {:title "Semantic Test Page"})
(.then (fn [{:keys [status] :as result}]
(is (= 201 status))
(let [page (json-body result)]
(is (= "Semantic Test Page" (:title page)))
(is (= "page" (:kind page)))
(.then (semantic-request host port "GET" (str "/v1/semantic/pages/" (:uuid page)))
(fn [{:keys [status] :as get-result}]
(is (= 200 status))
(is (= "Semantic Test Page" (:title (json-body get-result))))
page)))))
(.then (fn [page]
(.then (semantic-request host port "GET" "/v1/semantic/pages")
(fn [{:keys [status] :as list-result}]
(is (= 200 status))
(is (some #(= (:uuid page) (:uuid %)) (:blocks (json-body list-result))))
page))))
(.then (fn [page]
(.then (semantic-request host port "DELETE" (str "/v1/semantic/pages/" (:uuid page)))
(fn [{:keys [status]}]
(is (= 204 status))
(semantic-request host port "GET" (str "/v1/semantic/pages/" (:uuid page)))))))
(.then (fn [{:keys [status]}]
(is (= 404 status)))))))
(.finally done))))
(deftest semantic-blocks-property-and-capture-flow
(async done
(-> (with-daemon!
(fn [host port]
(-> (semantic-request host port "POST" "/v1/semantic/capture" {:blocks [{:title "Captured root"
:children [{:title "child"}]}]})
(.then (fn [{:keys [status] :as result}]
(is (= 201 status))
(let [body (json-body result)
root (first (:blocks body))]
(is (= "Captured root" (:title root)))
(is (= 1 (count (:children root))))
root)))
(.then (fn [root]
(.then (semantic-request host port "POST" "/v1/semantic/properties"
{:title "semantic-test-prop" :type "string"
:cardinality "db.cardinality/one"})
(fn [{:keys [status] :as property-result}]
(is (= 201 status))
[root (json-body property-result)]))))
(.then (fn [[root property]]
(.then (semantic-request host port "PUT"
(str "/v1/semantic/blocks/" (:uuid root)
"/properties/" (:uuid property))
{:value "hello"})
(fn [{:keys [status] :as set-result}]
(is (= 200 status))
(is (:updated (json-body set-result)))
[root property]))))
(.then (fn [[root property]]
(semantic-request host port "DELETE"
(str "/v1/semantic/blocks/" (:uuid root)
"/properties/" (:uuid property)))))
(.then (fn [{:keys [status]}]
(is (= 204 status)))))))
(.finally done))))
(deftest semantic-tags-and-tasks-flow
(async done
(-> (with-daemon!
(fn [host port]
(-> (semantic-request host port "POST" "/v1/semantic/tags" {:title "Semantic Tag"})
(.then (fn [{:keys [status] :as result}]
(is (= 201 status))
(is (= "Semantic Tag" (:title (json-body result))))))
(.then (fn [_]
(semantic-request host port "POST" "/v1/semantic/tasks"
{:title "Semantic Task" :status "doing" :priority "high"})))
(.then (fn [{:keys [status] :as result}]
(is (= 201 status))
(let [task (json-body result)]
(is (= "logseq.property/status.doing" (get-in task [:status :ident])))
(is (= "logseq.property/priority.high" (get-in task [:priority :ident]))))))
(.then (fn [_]
(semantic-request host port "GET" "/v1/semantic/tasks?status=doing")))
(.then (fn [{:keys [status] :as result}]
(is (= 200 status))
(is (some #(= "Semantic Task" (:title %)) (:tasks (json-body result)))))))))
(.finally done))))
(deftest semantic-search-finds-created-page
(async done
(-> (with-daemon!
(fn [host port]
(-> (semantic-request host port "POST" "/v1/semantic/pages" {:title "Findable Semantic Page"})
(.then (fn [_]
(semantic-request host port "GET" "/v1/semantic/search?q=Findable")))
(.then (fn [{:keys [status] :as result}]
(is (= 200 status))
(is (some #(= "Findable Semantic Page" (:title %)) (:results (json-body result)))))))))
(.finally done))))
(deftest semantic-request-requires-auth
(async done
(-> (with-daemon!
(fn [host port]
(-> (http-request {:hostname host :port port :path "/v1/semantic/pages" :method "GET"} nil)
(.then (fn [{:keys [status]}]
(is (= 401 status)))))))
(.finally done))))
(deftest semantic-unknown-route-is-not-found
(async done
(-> (with-daemon!
(fn [host port]
(-> (semantic-request host port "GET" "/v1/semantic/not-a-real-route")
(.then (fn [{:keys [status]}]
(is (= 404 status)))))))
(.finally done))))
(deftest semantic-openapi-document-lists-operations
(async done
(-> (with-daemon!
(fn [host port]
(-> (semantic-request host port "GET" "/v1/semantic/openapi.json")
(.then (fn [{:keys [status] :as result}]
(is (= 200 status))
(let [doc (json-body result)]
(is (= "3.1.0" (:openapi doc)))
(is (contains? (:paths doc) (keyword "/v1/semantic/pages")))
(is (not (contains? (:paths doc) (keyword "/v1/graphs/{graph-id}/semantic/pages"))))))))))
(.finally done))))