impl 007-logseq-cli-thread-api-and-command-split.md

This commit is contained in:
rcmerci
2026-01-19 23:43:20 +08:00
parent 1abe01a942
commit 4b7946c3b8
18 changed files with 1636 additions and 1545 deletions

View File

@@ -68,6 +68,22 @@
payload
(ldb/write-transit-str payload)))
(defn- normalize-method-kw
[method]
(cond
(keyword? method) method
(string? method) (keyword method)
(nil? method) nil
:else (keyword (str method))))
(defn- normalize-method-str
[method]
(cond
(keyword? method) (subs (str method) 1)
(string? method) method
(nil? method) nil
:else (str method)))
(defn- handle-event!
[type payload]
(let [event (js/JSON.stringify (clj->js {:type type}
@@ -90,7 +106,7 @@
(swap! *sse-clients disj res))))
(defn- <invoke!
[^js proxy method direct-pass? args]
[^js proxy method-str method-kw direct-pass? args]
(let [args' (if direct-pass?
(into-array (or args []))
(if (string? args)
@@ -100,22 +116,24 @@
timeout-id (js/setTimeout
(fn []
(log/warn :db-worker-node-invoke-timeout
{:method method
{:method (or method-kw method-str)
:elapsed-ms (- (js/Date.now) started-at)}))
10000)]
(-> (.remoteInvoke proxy method (boolean direct-pass?) args')
(-> (.remoteInvoke proxy method-str (boolean direct-pass?) args')
(p/finally (fn []
(js/clearTimeout timeout-id))))))
(defn- <init-worker!
[proxy rtc-ws-url]
(<invoke! proxy "thread-api/init" true #js [rtc-ws-url]))
(let [method-kw :thread-api/init
method-str (normalize-method-str method-kw)]
(<invoke! proxy method-str method-kw true #js [rtc-ws-url])))
(def ^:private non-repo-methods
#{"thread-api/init"
"thread-api/list-db"
"thread-api/get-version"
"thread-api/set-infer-worker-proxy"})
#{:thread-api/init
:thread-api/list-db
:thread-api/get-version
:thread-api/set-infer-worker-proxy})
(defn- repo-arg
[args]
@@ -126,23 +144,24 @@
(defn- repo-error
[method args bound-repo]
(when-not (contains? non-repo-methods method)
(let [repo (repo-arg args)]
(cond
(not (seq repo))
{:status 400
:error {:code :missing-repo
:message "repo is required"}}
(let [method-kw (normalize-method-kw method)]
(when-not (contains? non-repo-methods method-kw)
(let [repo (repo-arg args)]
(cond
(not (seq repo))
{:status 400
:error {:code :missing-repo
:message "repo is required"}}
(not= repo bound-repo)
{:status 409
:error {:code :repo-mismatch
:message "repo does not match bound repo"
:repo repo
:bound-repo bound-repo}}
(not= repo bound-repo)
{:status 409
:error {:code :repo-mismatch
:message "repo does not match bound repo"
:repo repo
:bound-repo bound-repo}}
:else
nil))))
:else
nil)))))
(defn- set-main-thread-stub!
[]
@@ -177,6 +196,8 @@
(-> (p/let [body (<read-body req)
payload (js/JSON.parse body)
{:keys [method directPass argsTransit args]} (js->clj payload :keywordize-keys true)
method-kw (normalize-method-kw method)
method-str (normalize-method-str method)
direct-pass? (boolean directPass)
args' (if direct-pass?
args
@@ -186,9 +207,9 @@
(if (string? args')
(ldb/read-transit-str args')
args'))]
(if-let [{:keys [status error]} (repo-error method args-for-validation bound-repo)]
(if-let [{:keys [status error]} (repo-error method-kw args-for-validation bound-repo)]
(send-json! res status {:ok false :error error})
(p/let [result (<invoke! proxy method direct-pass? args')]
(p/let [result (<invoke! proxy method-str method-kw direct-pass? args')]
(send-json! res 200 (if direct-pass?
{:ok true :result result}
{:ok true :resultTransit result})))))
@@ -311,7 +332,9 @@
:host host
:port port})
_ (reset! *lock-info {:path path :lock lock})
_ (<invoke! proxy "thread-api/create-or-open-db" false [repo {}])]
_ (let [method-kw :thread-api/create-or-open-db
method-str (normalize-method-str method-kw)]
(<invoke! proxy method-str method-kw false [repo {}]))]
(let [stop!* (atom nil)
server (make-server proxy {:auth-token auth-token
:bound-repo repo

View File

@@ -0,0 +1,147 @@
(ns logseq.cli.command.add
"Add-related CLI commands."
(:require ["fs" :as fs]
[cljs-time.coerce :as tc]
[cljs.reader :as reader]
[clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[promesa.core :as p]))
(def ^:private content-add-spec
{:content {:desc "Block content for add"}
:blocks {:desc "EDN vector of blocks for add"}
:blocks-file {:desc "EDN file of blocks for add"}
:page {:desc "Page name"}
:parent {:desc "Parent block UUID for add"}})
(def ^:private add-page-spec
{:page {:desc "Page name"}})
(def entries
[(core/command-entry ["add" "block"] :add-block "Add blocks" content-add-spec)
(core/command-entry ["add" "page"] :add-page "Create page" add-page-spec)])
(defn- today-page-title
[config repo]
(p/let [journal (transport/invoke config :thread-api/pull false
[repo [:logseq.property.journal/title-format] :logseq.class/Journal])
formatter (or (:logseq.property.journal/title-format journal) "MMM do, yyyy")
now (tc/from-date (js/Date.))]
(date-time-util/format now formatter)))
(defn- ensure-page!
[config repo page-name]
(p/let [page (transport/invoke config :thread-api/pull false
[repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])]
(if (:db/id page)
page
(p/let [_ (transport/invoke config :thread-api/apply-outliner-ops false
[repo [[:create-page [page-name {}]]] {}])]
(transport/invoke config :thread-api/pull false
[repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])))))
(defn- resolve-add-target
[config {:keys [repo page parent]}]
(if (seq parent)
(if-not (common-util/uuid-string? parent)
(p/rejected (ex-info "parent must be a uuid" {:code :invalid-parent}))
(p/let [block (transport/invoke config :thread-api/pull false
[repo [:db/id :block/uuid :block/title] [:block/uuid (uuid parent)]])]
(if-let [id (:db/id block)]
id
(throw (ex-info "parent block not found" {:code :parent-not-found})))))
(p/let [page-name (if (seq page) page (today-page-title config repo))
page-entity (ensure-page! config repo page-name)]
(or (:db/id page-entity)
(throw (ex-info "page not found" {:code :page-not-found}))))))
(defn- read-blocks
[options command-args]
(cond
(seq (:blocks options))
{:ok? true :value (reader/read-string (:blocks options))}
(seq (:blocks-file options))
(let [contents (.toString (fs/readFileSync (:blocks-file options)) "utf8")]
{:ok? true :value (reader/read-string contents)})
(seq (:content options))
{:ok? true :value [{:block/title (:content options)}]}
(seq command-args)
{:ok? true :value [{:block/title (string/join " " command-args)}]}
:else
{:ok? false
:error {:code :missing-content
:message "content is required"}}))
(defn- ensure-blocks
[value]
(if (vector? value)
{:ok? true :value value}
{:ok? false
:error {:code :invalid-blocks
:message "blocks must be a vector"}}))
(defn build-add-block-action
[options args repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for add"}}
(let [blocks-result (read-blocks options args)]
(if-not (:ok? blocks-result)
blocks-result
(let [vector-result (ensure-blocks (:value blocks-result))]
(if-not (:ok? vector-result)
vector-result
{:ok? true
:action {:type :add-block
:repo repo
:graph (core/repo->graph repo)
:page (:page options)
:parent (:parent options)
:blocks (:value vector-result)}}))))))
(defn build-add-page-action
[options repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for add"}}
(let [page (some-> (:page options) string/trim)]
(if (seq page)
{:ok? true
:action {:type :add-page
:repo repo
:graph (core/repo->graph repo)
:page page}}
{:ok? false
:error {:code :missing-page-name
:message "page name is required"}}))))
(defn execute-add-block
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
target-id (resolve-add-target cfg action)
ops [[:insert-blocks [(:blocks action)
target-id
{:sibling? false
:bottom? true
:outliner-op :insert-blocks}]]]
result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])]
{:status :ok
:data {:result result}})))
(defn execute-add-page
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
ops [[:create-page [(:page action) {}]]]
result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])]
{:status :ok
:data {:result result}})))

View File

@@ -0,0 +1,216 @@
(ns logseq.cli.command.core
"Shared CLI parsing utilities."
(:require [babashka.cli :as cli]
[clojure.string :as string]
[logseq.common.config :as common-config]))
(def ^:private global-spec*
{:help {:alias :h
:desc "Show help"
:coerce :boolean}
:config {:desc "Path to cli.edn"}
:auth-token {:desc "Auth token for db-worker-node"}
:repo {:desc "Graph name"}
:data-dir {:desc "Path to db-worker data dir"}
:timeout-ms {:desc "Request timeout in ms"
:coerce :long}
:retries {:desc "Retry count for requests"
:coerce :long}
:output {:desc "Output format (human, json, edn)"}})
(defn global-spec
[]
global-spec*)
(defn- merge-spec
[spec]
(merge global-spec* (or spec {})))
(defn command-entry
[cmds command desc spec]
(let [spec* (merge-spec spec)]
{:cmds cmds
:command command
:desc desc
:spec spec*
:restrict true
:fn (fn [{:keys [opts args]}]
{:command command
:cmds cmds
:spec spec*
:opts opts
:args args})}))
(defn- format-commands
[table]
(let [rows (->> table
(filter (comp seq :cmds))
(map (fn [{:keys [cmds desc spec]}]
(let [command (str (string/join " " cmds)
(when (seq spec) " [options]"))]
{:command command
:desc desc}))))
width (apply max 0 (map (comp count :command) rows))]
(->> rows
(map (fn [{:keys [command desc]}]
(let [padding (apply str (repeat (- width (count command)) " "))]
(cond-> (str " " command padding)
(seq desc) (str " " desc)))))
(string/join "\n"))))
(defn group-summary
[group table]
(let [group-table (filter #(= group (first (:cmds %))) table)]
(string/join "\n"
[(str "Usage: logseq " group " <subcommand> [options]")
""
"Subcommands:"
(format-commands group-table)
""
"Global options:"
(cli/format-opts {:spec global-spec*})
""
"Command options:"
(str " See `logseq " group " <subcommand> --help`")])))
(defn top-level-summary
[table]
(let [groups [{:title "Graph Inspect and Edit"
:commands #{"list" "add" "remove" "search" "show"}}
{:title "Graph Management"
:commands #{"graph" "server"}}]
render-group (fn [{:keys [title commands]}]
(let [entries (filter #(contains? commands (first (:cmds %))) table)]
(string/join "\n" [title (format-commands entries)])))]
(string/join "\n"
["Usage: logseq <command> [options]"
""
"Commands:"
(string/join "\n\n" (map render-group groups))
""
"Global options:"
(cli/format-opts {:spec global-spec*})
""
"Command options:"
" See `logseq <command> --help`"])))
(defn command-summary
[{:keys [cmds spec]}]
(let [command-spec (apply dissoc spec (keys global-spec*))]
(string/join "\n"
[(str "Usage: logseq " (string/join " " cmds) " [options]")
""
"Global options:"
(cli/format-opts {:spec global-spec*})
""
"Command options:"
(cli/format-opts {:spec command-spec})])))
(defn normalize-opts
[opts]
(cond-> opts
(:config opts) (-> (assoc :config-path (:config opts))
(dissoc :config))))
(defn ok-result
[command opts args summary]
{:ok? true
:command command
:options (normalize-opts opts)
:args (vec args)
:summary summary})
(defn help-result
[summary]
{:ok? false
:help? true
:summary summary})
(defn invalid-options-result
[summary message]
{:ok? false
:error {:code :invalid-options
:message message}
:summary summary})
(defn unknown-command-result
[summary message]
{:ok? false
:error {:code :unknown-command
:message message}
:summary summary})
(def ^:private global-aliases
(->> global-spec*
(keep (fn [[k {:keys [alias]}]]
(when alias
[alias k])))
(into {})))
(def ^:private global-flag-options
(->> global-spec*
(keep (fn [[k {:keys [coerce]}]]
(when (= coerce :boolean) k)))
(set)))
(defn- global-opt-key
[token]
(cond
(string/starts-with? token "--")
(keyword (subs token 2))
(and (string/starts-with? token "-")
(= 2 (count token)))
(get global-aliases (keyword (subs token 1)))
:else nil))
(defn parse-leading-global-opts
[args]
(loop [remaining args
opts {}]
(if (empty? remaining)
{:opts opts :args []}
(let [token (first remaining)]
(if-let [opt-key (global-opt-key token)]
(if (contains? global-flag-options opt-key)
(recur (rest remaining) (assoc opts opt-key true))
(if-let [value (second remaining)]
(recur (drop 2 remaining) (assoc opts opt-key value))
{:opts opts :args (rest remaining)}))
{:opts opts :args remaining})))))
(defn legacy-graph-opt?
[raw-args]
(some (fn [token]
(or (= token "--graph")
(string/starts-with? token "--graph=")))
raw-args))
(defn cli-error->result
[summary {:keys [msg]}]
(invalid-options-result summary (or msg "invalid options")))
(defn graph->repo
[graph]
(when (seq graph)
(if (string/starts-with? graph common-config/db-version-prefix)
graph
(str common-config/db-version-prefix graph))))
(defn repo->graph
[repo]
(when (seq repo)
(string/replace-first repo common-config/db-version-prefix "")))
(defn resolve-repo
[graph]
(let [graph (some-> graph string/trim)]
(when (seq graph)
(graph->repo graph))))
(defn pick-graph
[options command-args config]
(or (:repo options)
(first command-args)
(:repo config)))

View File

@@ -0,0 +1,226 @@
(ns logseq.cli.command.graph
"Graph-related CLI commands."
(:require [clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.config :as cli-config]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[promesa.core :as p]))
(def ^:private graph-export-spec
{:type {:desc "Export type (edn, sqlite)"}
:output {:desc "Output path"}})
(def ^:private graph-import-spec
{:type {:desc "Import type (edn, sqlite)"}
:input {:desc "Input path"}})
(def entries
[(core/command-entry ["graph" "list"] :graph-list "List graphs" {})
(core/command-entry ["graph" "create"] :graph-create "Create graph" {})
(core/command-entry ["graph" "switch"] :graph-switch "Switch current graph" {})
(core/command-entry ["graph" "remove"] :graph-remove "Remove graph" {})
(core/command-entry ["graph" "validate"] :graph-validate "Validate graph" {})
(core/command-entry ["graph" "info"] :graph-info "Graph metadata" {})
(core/command-entry ["graph" "export"] :graph-export "Export graph" graph-export-spec)
(core/command-entry ["graph" "import"] :graph-import "Import graph" graph-import-spec)])
(def ^:private import-export-types*
#{"edn" "sqlite"})
(defn import-export-types
[]
import-export-types*)
(defn normalize-import-export-type
[value]
(some-> value string/lower-case string/trim))
(defn- missing-graph-error
[]
{:ok? false
:error {:code :missing-graph
:message "graph name is required"}})
(defn build-graph-action
[command graph repo]
(case command
:graph-list
{:ok? true
:action {:type :graph-list
:command :graph-list}}
:graph-create
(if-not (seq graph)
(missing-graph-error)
{:ok? true
:action {:type :invoke
:command :graph-create
:method :thread-api/create-or-open-db
:direct-pass? false
:args [repo {}]
:repo repo
:graph (core/repo->graph repo)
:allow-missing-graph true
:persist-repo (core/repo->graph repo)}})
:graph-switch
(if-not (seq graph)
(missing-graph-error)
{:ok? true
:action {:type :graph-switch
:command :graph-switch
:repo repo
:graph (core/repo->graph repo)}})
:graph-remove
(if-not (seq graph)
(missing-graph-error)
{:ok? true
:action {:type :invoke
:command :graph-remove
:method :thread-api/unsafe-unlink-db
:direct-pass? false
:args [repo]
:repo repo
:graph (core/repo->graph repo)}})
:graph-validate
(if-not (seq repo)
(missing-graph-error)
{:ok? true
:action {:type :invoke
:command :graph-validate
:method :thread-api/validate-db
:direct-pass? false
:args [repo]
:repo repo
:graph (core/repo->graph repo)}})
:graph-info
(if-not (seq repo)
(missing-graph-error)
{:ok? true
:action {:type :graph-info
:command :graph-info
:repo repo
:graph (core/repo->graph repo)}})))
(defn build-export-action
[repo export-type output]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for export"}}
{:ok? true
:action {:type :graph-export
:repo repo
:graph (core/repo->graph repo)
:export-type export-type
:output output}}))
(defn build-import-action
[repo import-type input]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for import"}}
{:ok? true
:action {:type :graph-import
:repo repo
:graph (core/repo->graph repo)
:import-type import-type
:input input
:allow-missing-graph true}}))
(defn execute-graph-list
[_action config]
(let [graphs (cli-server/list-graphs config)]
{:status :ok
:data {:graphs graphs}}))
(defn execute-invoke
[action config]
(-> (p/let [cfg (if-let [repo (:repo action)]
(cli-server/ensure-server! config repo)
(p/resolved config))
result (transport/invoke cfg
(:method action)
(:direct-pass? action)
(:args action))]
(when-let [repo (:persist-repo action)]
(cli-config/update-config! config {:repo repo}))
(if-let [write (:write action)]
(let [{:keys [format path]} write]
(transport/write-output {:format format :path path :data result})
{:status :ok
:data {:message (str "wrote " path)}})
{:status :ok :data {:result result}}))))
(defn execute-graph-switch
[action config]
(-> (p/let [graphs (cli-server/list-graphs config)
graph (:graph action)]
(if-not (some #(= graph %) graphs)
{:status :error
:error {:code :graph-not-found
:message (str "graph not found: " graph)}}
(p/let [_ (cli-server/ensure-server! config (:repo action))]
(cli-config/update-config! config {:repo graph})
{:status :ok
:data {:message (str "switched to " graph)}})))))
(defn execute-graph-info
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
created (transport/invoke cfg :thread-api/pull false [(:repo action) [:kv/value] :logseq.kv/graph-created-at])
schema (transport/invoke cfg :thread-api/pull false [(:repo action) [:kv/value] :logseq.kv/schema-version])]
{:status :ok
:data {:graph (:graph action)
:logseq.kv/graph-created-at (:kv/value created)
:logseq.kv/schema-version (:kv/value schema)}})))
(defn execute-graph-export
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
export-type (:export-type action)
export-result (case export-type
"edn"
(transport/invoke cfg
:thread-api/export-edn
false
[(:repo action) {:export-type :graph}])
"sqlite"
(transport/invoke cfg
:thread-api/export-db-base64
true
[(:repo action)])
(throw (ex-info "unsupported export type" {:export-type export-type})))
data (if (= export-type "sqlite")
(js/Buffer.from export-result "base64")
export-result)
format (if (= export-type "sqlite") :sqlite :edn)]
(transport/write-output {:format format :path (:output action) :data data})
{:status :ok
:data {:message (str "wrote " (:output action))}})))
(defn execute-graph-import
[action config]
(-> (p/let [_ (cli-server/stop-server! config (:repo action))
cfg (cli-server/ensure-server! config (:repo action))
import-type (:import-type action)
input-data (case import-type
"edn" (transport/read-input {:format :edn :path (:input action)})
"sqlite" (transport/read-input {:format :sqlite :path (:input action)})
(throw (ex-info "unsupported import type" {:import-type import-type})))
payload (if (= import-type "sqlite")
(.toString (js/Buffer.from input-data) "base64")
input-data)
method (if (= import-type "sqlite")
:thread-api/import-db-base64
:thread-api/import-edn)
direct-pass? (= import-type "sqlite")
_ (transport/invoke cfg method direct-pass? [(:repo action) payload])
_ (cli-server/restart-server! config (:repo action))]
{:status :ok
:data {:message (str "imported " import-type " from " (:input action))}})))

View File

@@ -0,0 +1,212 @@
(ns logseq.cli.command.list
"List-related CLI commands."
(:require [clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[promesa.core :as p]))
(def ^:private list-common-spec
{:expand {:desc "Include expanded metadata"
:coerce :boolean}
:limit {:desc "Limit results"
:coerce :long}
:offset {:desc "Offset results"
:coerce :long}
:sort {:desc "Sort field"}
:order {:desc "Sort order (asc, desc)"}})
(def ^:private list-page-spec
(merge list-common-spec
{:include-journal {:desc "Include journal pages"
:coerce :boolean}
:journal-only {:desc "Only journal pages"
:coerce :boolean}
:include-hidden {:desc "Include hidden pages"
:coerce :boolean}
:updated-after {:desc "Filter by updated-at (ISO8601)"}
:created-after {:desc "Filter by created-at (ISO8601)"}
:fields {:desc "Select output fields (comma separated)"}}))
(def ^:private list-tag-spec
(merge list-common-spec
{:include-built-in {:desc "Include built-in tags"
:coerce :boolean}
:with-properties {:desc "Include tag properties"
:coerce :boolean}
:with-extends {:desc "Include tag extends"
:coerce :boolean}
:fields {:desc "Select output fields (comma separated)"}}))
(def ^:private list-property-spec
(merge list-common-spec
{:include-built-in {:desc "Include built-in properties"
:coerce :boolean}
:with-classes {:desc "Include property classes"
:coerce :boolean}
:with-type {:desc "Include property type"
:coerce :boolean}
:fields {:desc "Select output fields (comma separated)"}}))
(def entries
[(core/command-entry ["list" "page"] :list-page "List pages" list-page-spec)
(core/command-entry ["list" "tag"] :list-tag "List tags" list-tag-spec)
(core/command-entry ["list" "property"] :list-property "List properties" list-property-spec)])
(def ^:private list-sort-fields
{:list-page #{"title" "created-at" "updated-at"}
:list-tag #{"name" "title"}
:list-property #{"name" "title"}})
(defn invalid-options?
[command opts]
(let [{:keys [order include-journal journal-only]} opts
sort-field (:sort opts)
allowed (get list-sort-fields command)]
(cond
(and include-journal journal-only)
"include-journal and journal-only are mutually exclusive"
(and (seq sort-field) (not (contains? allowed sort-field)))
(str "invalid sort field: " sort-field)
(and (seq order) (not (#{"asc" "desc"} order)))
(str "invalid order: " order)
:else nil)))
(defn build-action
[command options repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for list"}}
{:ok? true
:action {:type command
:repo repo
:options options}}))
(def ^:private list-page-field-map
{"title" :block/title
"uuid" :block/uuid
"created-at" :block/created-at
"updated-at" :block/updated-at})
(def ^:private list-tag-field-map
{"name" :block/title
"title" :block/title
"uuid" :block/uuid
"properties" :logseq.property.class/properties
"extends" :logseq.property.class/extends
"description" :logseq.property/description})
(def ^:private list-property-field-map
{"name" :block/title
"title" :block/title
"uuid" :block/uuid
"classes" :logseq.property/classes
"type" :logseq.property/type
"description" :logseq.property/description})
(defn- parse-field-list
[fields]
(when (seq fields)
(->> (string/split fields #",")
(map string/trim)
(remove string/blank?)
vec)))
(defn- apply-fields
[items fields field-map]
(if (seq fields)
(let [keys (->> fields
(map #(get field-map %))
(remove nil?)
vec)]
(if (seq keys)
(mapv #(select-keys % keys) items)
items))
items))
(defn- apply-sort
[items sort-field order field-map]
(if (seq sort-field)
(let [sort-key (get field-map sort-field)
sorted (if sort-key
(sort-by #(get % sort-key) items)
items)
sorted (if (= "desc" order) (reverse sorted) sorted)]
(vec sorted))
(vec items)))
(defn- apply-offset-limit
[items offset limit]
(cond-> items
(some? offset) (->> (drop offset) vec)
(some? limit) (->> (take limit) vec)))
(defn- prepare-tag-item
[item {:keys [expand with-properties with-extends]}]
(if expand
(cond-> item
(not with-properties) (dissoc :logseq.property.class/properties)
(not with-extends) (dissoc :logseq.property.class/extends))
item))
(defn- prepare-property-item
[item {:keys [expand with-classes with-type]}]
(if expand
(cond-> item
(not with-classes) (dissoc :logseq.property/classes)
(not with-type) (dissoc :logseq.property/type))
item))
(defn execute-list-page
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
options (:options action)
items (transport/invoke cfg :thread-api/api-list-pages false
[(:repo action) options])
order (or (:order options) "asc")
fields (parse-field-list (:fields options))
sorted (apply-sort items (:sort options) order list-page-field-map)
limited (apply-offset-limit sorted (:offset options) (:limit options))
final (if (:expand options)
(apply-fields limited fields list-page-field-map)
limited)]
{:status :ok
:data {:items final}})))
(defn execute-list-tag
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
options (:options action)
items (transport/invoke cfg :thread-api/api-list-tags false
[(:repo action) options])
order (or (:order options) "asc")
fields (parse-field-list (:fields options))
prepared (mapv #(prepare-tag-item % options) items)
sorted (apply-sort prepared (:sort options) order list-tag-field-map)
limited (apply-offset-limit sorted (:offset options) (:limit options))
final (if (:expand options)
(apply-fields limited fields list-tag-field-map)
limited)]
{:status :ok
:data {:items final}})))
(defn execute-list-property
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
options (:options action)
items (transport/invoke cfg :thread-api/api-list-properties false
[(:repo action) options])
order (or (:order options) "asc")
fields (parse-field-list (:fields options))
prepared (mapv #(prepare-property-item % options) items)
sorted (apply-sort prepared (:sort options) order list-property-field-map)
limited (apply-offset-limit sorted (:offset options) (:limit options))
final (if (:expand options)
(apply-fields limited fields list-property-field-map)
limited)]
{:status :ok
:data {:items final}})))

View File

@@ -0,0 +1,81 @@
(ns logseq.cli.command.remove
"Remove-related CLI commands."
(:require [clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[logseq.common.util :as common-util]
[promesa.core :as p]))
(def ^:private remove-block-spec
{:block {:desc "Block UUID"}})
(def ^:private remove-page-spec
{:page {:desc "Page name"}})
(def entries
[(core/command-entry ["remove" "block"] :remove-block "Remove block" remove-block-spec)
(core/command-entry ["remove" "page"] :remove-page "Remove page" remove-page-spec)])
(defn- perform-remove
[config {:keys [repo block page]}]
(cond
(seq block)
(if-not (common-util/uuid-string? block)
(p/rejected (ex-info "block must be a uuid" {:code :invalid-block}))
(p/let [entity (transport/invoke config :thread-api/pull false
[repo [:db/id :block/uuid] [:block/uuid (uuid block)]])]
(if-let [id (:db/id entity)]
(transport/invoke config :thread-api/apply-outliner-ops false
[repo [[:delete-blocks [[id] {}]]] {}])
(throw (ex-info "block not found" {:code :block-not-found})))))
(seq page)
(p/let [entity (transport/invoke config :thread-api/pull false
[repo [:db/id :block/uuid] [:block/name page]])]
(if-let [page-uuid (:block/uuid entity)]
(transport/invoke config :thread-api/apply-outliner-ops false
[repo [[:delete-page [page-uuid]]] {}])
(throw (ex-info "page not found" {:code :page-not-found}))))
:else
(p/rejected (ex-info "block or page required" {:code :missing-target}))))
(defn build-remove-block-action
[options repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for remove"}}
(let [block (some-> (:block options) string/trim)]
(if (seq block)
{:ok? true
:action {:type :remove-block
:repo repo
:block block}}
{:ok? false
:error {:code :missing-target
:message "block is required"}}))))
(defn build-remove-page-action
[options repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for remove"}}
(let [page (some-> (:page options) string/trim)]
(if (seq page)
{:ok? true
:action {:type :remove-page
:repo repo
:page page}}
{:ok? false
:error {:code :missing-target
:message "page is required"}}))))
(defn execute-remove
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
result (perform-remove cfg action)]
{:status :ok
:data {:result result}})))

View File

@@ -0,0 +1,240 @@
(ns logseq.cli.command.search
"Search-related CLI commands."
(:require [clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[promesa.core :as p]))
(def ^:private search-spec
{:text {:desc "Search text"}
:type {:desc "Search types (page, block, tag, property, all)"}
:tag {:desc "Restrict to a specific tag"}
:limit {:desc "Limit results"
:coerce :long}
:case-sensitive {:desc "Case sensitive search"
:coerce :boolean}
:include-content {:desc "Search block content"
:coerce :boolean}
:sort {:desc "Sort field (updated-at, created-at)"}
:order {:desc "Sort order (asc, desc)"}})
(def entries
[(core/command-entry ["search"] :search "Search graph" search-spec)])
(def ^:private search-types
#{"page" "block" "tag" "property" "all"})
(defn invalid-options?
[opts]
(let [type (:type opts)
order (:order opts)
sort-field (:sort opts)]
(cond
(and (seq type) (not (contains? search-types type)))
(str "invalid type: " type)
(and (seq sort-field) (not (#{"updated-at" "created-at"} sort-field)))
(str "invalid sort field: " sort-field)
(and (seq order) (not (#{"asc" "desc"} order)))
(str "invalid order: " order)
:else
nil)))
(defn build-action
[options args repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for search"}}
(let [text (or (:text options) (string/join " " args))]
(if (seq text)
{:ok? true
:action {:type :search
:repo repo
:text text
:search-type (:type options)
:tag (:tag options)
:limit (:limit options)
:case-sensitive (:case-sensitive options)
:include-content (:include-content options)
:sort (:sort options)
:order (:order options)}}
{:ok? false
:error {:code :missing-search-text
:message "search text is required"}}))))
(defn- query-pages
[cfg repo text case-sensitive?]
(let [query (if case-sensitive?
'[:find ?e ?title ?uuid ?updated ?created
:in $ ?q
:where
[?e :block/name ?name]
[?e :block/title ?title]
[?e :block/uuid ?uuid]
[(get-else $ ?e :block/updated-at 0) ?updated]
[(get-else $ ?e :block/created-at 0) ?created]
[(clojure.string/includes? ?title ?q)]]
'[:find ?e ?title ?uuid ?updated ?created
:in $ ?q
:where
[?e :block/name ?name]
[?e :block/title ?title]
[?e :block/uuid ?uuid]
[(get-else $ ?e :block/updated-at 0) ?updated]
[(get-else $ ?e :block/created-at 0) ?created]
[(clojure.string/includes? (clojure.string/lower-case ?title) ?q)]])
q* (if case-sensitive? text (string/lower-case text))]
(transport/invoke cfg :thread-api/q false [repo [query q*]])))
#_{:clj-kondo/ignore [:aliased-namespace-symbol]}
(defn- query-blocks
[cfg repo text case-sensitive? tag include-content?]
(let [has-tag? (seq tag)
content-attr (if include-content? :block/content :block/title)
query (cond
(and case-sensitive? has-tag?)
`[:find ?e ?value ?uuid ?updated ?created
:in $ ?q ?tag-name
:where
[?tag :block/name ?tag-name]
[?e :block/tags ?tag]
[?e ~content-attr ?value]
[(missing? $ ?e :block/name)]
[?e :block/uuid ?uuid]
[(get-else $ ?e :block/updated-at 0) ?updated]
[(get-else $ ?e :block/created-at 0) ?created]
[(clojure.string/includes? ?value ?q)]]
case-sensitive?
`[:find ?e ?value ?uuid ?updated ?created
:in $ ?q
:where
[?e ~content-attr ?value]
[(missing? $ ?e :block/name)]
[?e :block/uuid ?uuid]
[(get-else $ ?e :block/updated-at 0) ?updated]
[(get-else $ ?e :block/created-at 0) ?created]
[(clojure.string/includes? ?value ?q)]]
has-tag?
`[:find ?e ?value ?uuid ?updated ?created
:in $ ?q ?tag-name
:where
[?tag :block/name ?tag-name]
[?e :block/tags ?tag]
[?e ~content-attr ?value]
[(missing? $ ?e :block/name)]
[?e :block/uuid ?uuid]
[(get-else $ ?e :block/updated-at 0) ?updated]
[(get-else $ ?e :block/created-at 0) ?created]
[(clojure.string/includes? (clojure.string/lower-case ?value) ?q)]]
:else
`[:find ?e ?value ?uuid ?updated ?created
:in $ ?q
:where
[?e ~content-attr ?value]
[(missing? $ ?e :block/name)]
[?e :block/uuid ?uuid]
[(get-else $ ?e :block/updated-at 0) ?updated]
[(get-else $ ?e :block/created-at 0) ?created]
[(clojure.string/includes? (clojure.string/lower-case ?value) ?q)]])
q* (if case-sensitive? text (string/lower-case text))
tag-name (some-> tag string/lower-case)]
(if has-tag?
(transport/invoke cfg :thread-api/q false [repo [query q* tag-name]])
(transport/invoke cfg :thread-api/q false [repo [query q*]]))))
(defn- normalize-search-types
[type]
(let [type (or type "all")]
(case type
"page" [:page]
"block" [:block]
"tag" [:tag]
"property" [:property]
[:page :block :tag :property])))
(defn- search-sort-key
[item sort-field]
(case sort-field
"updated-at" (:updated-at item)
"created-at" (:created-at item)
nil))
(defn execute-search
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
types (normalize-search-types (:search-type action))
case-sensitive? (boolean (:case-sensitive action))
text (:text action)
tag (:tag action)
page-results (when (some #{:page} types)
(p/let [rows (query-pages cfg (:repo action) text case-sensitive?)]
(mapv (fn [[id title uuid updated created]]
{:type "page"
:db/id id
:title title
:uuid (str uuid)
:updated-at updated
:created-at created})
rows)))
include-content? (boolean (:include-content action))
block-results (when (some #{:block} types)
(p/let [rows (query-blocks cfg (:repo action) text case-sensitive? tag include-content?)]
(mapv (fn [[id content uuid updated created]]
{:type "block"
:db/id id
:content content
:uuid (str uuid)
:updated-at updated
:created-at created})
rows)))
tag-results (when (some #{:tag} types)
(p/let [items (transport/invoke cfg :thread-api/api-list-tags false
[(:repo action) {:expand true :include-built-in true}])
q* (if case-sensitive? text (string/lower-case text))]
(->> items
(filter (fn [item]
(let [title (:block/title item)]
(if case-sensitive?
(string/includes? title q*)
(string/includes? (string/lower-case title) q*)))))
(mapv (fn [item]
{:type "tag"
:title (:block/title item)
:uuid (:block/uuid item)})))))
property-results (when (some #{:property} types)
(p/let [items (transport/invoke cfg :thread-api/api-list-properties false
[(:repo action) {:expand true :include-built-in true}])
q* (if case-sensitive? text (string/lower-case text))]
(->> items
(filter (fn [item]
(let [title (:block/title item)]
(if case-sensitive?
(string/includes? title q*)
(string/includes? (string/lower-case title) q*)))))
(mapv (fn [item]
{:type "property"
:title (:block/title item)
:uuid (:block/uuid item)})))))
results (->> (concat (or page-results [])
(or block-results [])
(or tag-results [])
(or property-results []))
(distinct)
vec)
sorted (if-let [sort-field (:sort action)]
(let [order (or (:order action) "desc")]
(->> results
(sort-by #(search-sort-key % sort-field))
(cond-> (= order "desc") reverse)
vec))
results)
limited (if (some? (:limit action)) (vec (take (:limit action) sorted)) sorted)]
{:status :ok
:data {:results limited}})))

View File

@@ -0,0 +1,96 @@
(ns logseq.cli.command.server
"Server-related CLI commands."
(:require [logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[promesa.core :as p]))
(def ^:private server-spec
{:repo {:desc "Graph name"}})
(def entries
[(core/command-entry ["server" "list"] :server-list "List db-worker-node servers" {})
(core/command-entry ["server" "status"] :server-status "Show server status for a graph" server-spec)
(core/command-entry ["server" "start"] :server-start "Start db-worker-node for a graph" server-spec)
(core/command-entry ["server" "stop"] :server-stop "Stop db-worker-node for a graph" server-spec)
(core/command-entry ["server" "restart"] :server-restart "Restart db-worker-node for a graph" server-spec)])
(defn build-action
[command repo]
(case command
:server-list
{:ok? true
:action {:type :server-list}}
:server-status
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for server status"}}
{:ok? true
:action {:type :server-status
:repo repo}})
:server-start
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for server start"}}
{:ok? true
:action {:type :server-start
:repo repo}})
:server-stop
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for server stop"}}
{:ok? true
:action {:type :server-stop
:repo repo}})
:server-restart
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for server restart"}}
{:ok? true
:action {:type :server-restart
:repo repo}})
{:ok? false
:error {:code :unknown-command
:message (str "unknown server command: " command)}}))
(defn- server-result->response
[result]
(if (:ok? result)
{:status :ok
:data (:data result)}
{:status :error
:error (:error result)}))
(defn execute-list
[_action config]
(-> (p/let [servers (cli-server/list-servers config)]
{:status :ok
:data {:servers servers}})))
(defn execute-status
[action config]
(-> (p/let [result (cli-server/server-status config (:repo action))]
(server-result->response result))))
(defn execute-start
[action config]
(-> (p/let [result (cli-server/start-server! config (:repo action))]
(server-result->response result))))
(defn execute-stop
[action config]
(-> (p/let [result (cli-server/stop-server! config (:repo action))]
(server-result->response result))))
(defn execute-restart
[action config]
(-> (p/let [result (cli-server/restart-server! config (:repo action))]
(server-result->response result))))

View File

@@ -0,0 +1,189 @@
(ns logseq.cli.command.show
"Show-related CLI commands."
(:require [clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[logseq.common.util :as common-util]
[promesa.core :as p]))
(def ^:private show-spec
{:id {:desc "Block db/id"
:coerce :long}
:uuid {:desc "Block UUID"}
:page-name {:desc "Page name"}
:level {:desc "Limit tree depth"
:coerce :long}
:format {:desc "Output format (text, json, edn)"}})
(def entries
[(core/command-entry ["show"] :show "Show tree" show-spec)])
(def ^:private show-formats
#{"text" "json" "edn"})
(defn invalid-options?
[opts]
(let [format (:format opts)
level (:level opts)]
(cond
(and (seq format) (not (contains? show-formats (string/lower-case format))))
(str "invalid format: " format)
(and (some? level) (< level 1))
"level must be >= 1"
:else
nil)))
(def ^:private tree-block-selector
[:db/id :block/uuid :block/title :block/order {:block/parent [:db/id]}])
(defn- fetch-blocks-for-page
[config repo page-id]
(let [query [:find (list 'pull '?b tree-block-selector)
:in '$ '?page-id
:where ['?b :block/page '?page-id]]]
(p/let [rows (transport/invoke config :thread-api/q false [repo [query page-id]])]
(mapv first rows))))
(defn- build-tree
[blocks root-id max-depth]
(let [parent->children (group-by #(get-in % [:block/parent :db/id]) blocks)
sort-children (fn [children]
(vec (sort-by :block/order children)))
build (fn build [parent-id depth]
(mapv (fn [b]
(let [children (build (:db/id b) (inc depth))]
(cond-> b
(seq children) (assoc :block/children children))))
(if (and max-depth (>= depth max-depth))
[]
(sort-children (get parent->children parent-id)))))]
(build root-id 1)))
(defn- fetch-tree
[config {:keys [repo id page-name level] :as opts}]
(let [max-depth (or level 10)
uuid-str (:uuid opts)]
(cond
(some? id)
(p/let [entity (transport/invoke config :thread-api/pull false
[repo [:db/id :block/name :block/uuid :block/title {:block/page [:db/id :block/title]}] id])]
(if-let [page-id (get-in entity [:block/page :db/id])]
(p/let [blocks (fetch-blocks-for-page config repo page-id)
children (build-tree blocks (:db/id entity) max-depth)]
{:root (assoc entity :block/children children)})
(if (:db/id entity)
(p/let [blocks (fetch-blocks-for-page config repo (:db/id entity))
children (build-tree blocks (:db/id entity) max-depth)]
{:root (assoc entity :block/children children)})
(throw (ex-info "block not found" {:code :block-not-found})))))
(seq uuid-str)
(if-not (common-util/uuid-string? uuid-str)
(p/rejected (ex-info "block must be a uuid" {:code :invalid-block}))
(p/let [entity (transport/invoke config :thread-api/pull false
[repo [:db/id :block/name :block/uuid :block/title {:block/page [:db/id :block/title]}]
[:block/uuid (uuid uuid-str)]])
entity (if (:db/id entity)
entity
(transport/invoke config :thread-api/pull false
[repo [:db/id :block/name :block/uuid :block/title {:block/page [:db/id :block/title]}]
[:block/uuid uuid-str]]))]
(if-let [page-id (get-in entity [:block/page :db/id])]
(p/let [blocks (fetch-blocks-for-page config repo page-id)
children (build-tree blocks (:db/id entity) max-depth)]
{:root (assoc entity :block/children children)})
(if (:db/id entity)
(p/let [blocks (fetch-blocks-for-page config repo (:db/id entity))
children (build-tree blocks (:db/id entity) max-depth)]
{:root (assoc entity :block/children children)})
(throw (ex-info "block not found" {:code :block-not-found}))))))
(seq page-name)
(p/let [page-entity (transport/invoke config :thread-api/pull false
[repo [:db/id :block/uuid :block/title] [:block/name page-name]])]
(if-let [page-id (:db/id page-entity)]
(p/let [blocks (fetch-blocks-for-page config repo page-id)
children (build-tree blocks page-id max-depth)]
{:root (assoc page-entity :block/children children)})
(throw (ex-info "page not found" {:code :page-not-found}))))
:else
(p/rejected (ex-info "block or page required" {:code :missing-target})))))
(defn tree->text
[{:keys [root]}]
(let [label (fn [node]
(or (:block/title node) (:block/name node) (str (:block/uuid node))))
node-id (fn [node]
(or (:db/id node) "-"))
id-padding (fn [node]
(apply str (repeat (inc (count (str (node-id node)))) " ")))
split-lines (fn [value]
(string/split (or value "") #"\n"))
lines (atom [])
walk (fn walk [node prefix]
(let [children (:block/children node)
total (count children)]
(doseq [[idx child] (map-indexed vector children)]
(let [last-child? (= idx (dec total))
branch (if last-child? "└── " "├── ")
next-prefix (str prefix (if last-child? " " "│ "))
rows (split-lines (label child))
first-row (first rows)
rest-rows (rest rows)
line (str (node-id child) " " prefix branch first-row)]
(swap! lines conj line)
(doseq [row rest-rows]
(swap! lines conj (str (id-padding child) next-prefix row)))
(walk child next-prefix)))))]
(let [rows (split-lines (label root))
first-row (first rows)
rest-rows (rest rows)]
(swap! lines conj (str (node-id root) " " first-row))
(doseq [row rest-rows]
(swap! lines conj (str (id-padding root) row))))
(walk root "")
(string/join "\n" @lines)))
(defn build-action
[options repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
:message "repo is required for show"}}
(let [format (some-> (:format options) string/lower-case)
targets (filter some? [(:id options) (:uuid options) (:page-name options)])]
(if (empty? targets)
{:ok? false
:error {:code :missing-target
:message "block or page is required"}}
{:ok? true
:action {:type :show
:repo repo
:id (:id options)
:uuid (:uuid options)
:page-name (:page-name options)
:level (:level options)
:format format}}))))
(defn execute-show
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
tree-data (fetch-tree cfg action)
format (:format action)]
(case format
"edn"
{:status :ok
:data tree-data
:output-format :edn}
"json"
{:status :ok
:data tree-data
:output-format :json}
{:status :ok
:data {:message (tree->text tree-data)}}))))

File diff suppressed because it is too large Load Diff

View File

@@ -68,7 +68,7 @@
(defn- error-hint
[{:keys [code]}]
(case code
:missing-graph "Use --graph <name>"
:missing-graph "Use --repo <name>"
:missing-repo "Use --repo <name>"
:missing-content "Use --content or pass content as args"
:missing-search-text "Provide search text or --text"

View File

@@ -90,11 +90,16 @@
[{:keys [base-url auth-token timeout-ms retries]}
method direct-pass? args]
(let [url (str (string/replace base-url #"/$" "") "/v1/invoke")
method* (cond
(keyword? method) (subs (str method) 1)
(string? method) method
(nil? method) nil
:else (str method))
payload (if direct-pass?
{:method method
{:method method*
:directPass true
:args args}
{:method method
{:method method*
:directPass false
:argsTransit (ldb/write-transit-str args)})
body (js/JSON.stringify (clj->js payload))]

View File

@@ -180,6 +180,22 @@
(is (= "logseq_db_parse_args" (:repo result)))
(is (= "/tmp/db-worker" (:data-dir result)))))
(deftest db-worker-node-repo-error-handles-keyword-methods
(let [repo-error #'db-worker-node/repo-error
bound-repo "logseq_db_bound"]
(is (nil? (repo-error :thread-api/list-db [] bound-repo)))
(is (nil? (repo-error "thread-api/list-db" [] bound-repo)))
(is (= {:status 400
:error {:code :missing-repo
:message "repo is required"}}
(repo-error :thread-api/create-or-open-db [] bound-repo)))
(is (= {:status 409
:error {:code :repo-mismatch
:message "repo does not match bound repo"
:repo "other"
:bound-repo bound-repo}}
(repo-error :thread-api/create-or-open-db ["other"] bound-repo)))))
(deftest db-worker-node-daemon-smoke-test
(async done
(let [daemon (atom nil)

View File

@@ -1,6 +1,7 @@
(ns logseq.cli.commands-test
(:require [cljs.test :refer [async deftest is testing]]
[clojure.string :as string]
[logseq.cli.command.show :as show-command]
[logseq.cli.commands :as commands]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
@@ -127,6 +128,13 @@
(is (false? (:ok? result)))
(is (= :unknown-command (get-in result [:error :code]))))))
(deftest test-parse-args-rejects-graph-option
(testing "rejects legacy --graph option"
(let [result (commands/parse-args ["--graph" "demo" "graph" "list"])]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code])))
(is (= "unknown option: --graph" (get-in result [:error :message]))))))
(deftest test-parse-args-global-options
(testing "global output option is accepted"
(let [result (commands/parse-args ["--output" "json" "graph" "list"])]
@@ -135,7 +143,7 @@
(deftest test-tree->text-format
(testing "show tree text uses db/id with tree glyphs"
(let [tree->text #'commands/tree->text
(let [tree->text #'show-command/tree->text
tree-data {:root {:db/id 1
:block/title "Root"
:block/children [{:db/id 2
@@ -152,7 +160,7 @@
(deftest test-tree->text-multiline
(testing "show tree text renders multiline blocks under glyph column"
(let [tree->text #'commands/tree->text
(let [tree->text #'show-command/tree->text
tree-data {:root {:db/id 168
:block/title "Jan 18th, 2026"
:block/children [{:db/id 169
@@ -244,7 +252,7 @@
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code]))))))
(deftest test-verb-subcommand-parse
(deftest test-verb-subcommand-parse-add-remove
(testing "add block requires content source"
(let [result (commands/parse-args ["add" "block"])]
(is (false? (:ok? result)))
@@ -282,8 +290,9 @@
(let [result (commands/parse-args ["remove" "page" "--page" "Home"])]
(is (true? (:ok? result)))
(is (= :remove-page (:command result)))
(is (= "Home" (get-in result [:options :page])))))
(is (= "Home" (get-in result [:options :page]))))))
(deftest test-verb-subcommand-parse-search-show
(testing "search requires text"
(let [result (commands/parse-args ["search"])]
(is (false? (:ok? result)))
@@ -304,8 +313,9 @@
(let [result (commands/parse-args ["show" "--page-name" "Home"])]
(is (true? (:ok? result)))
(is (= :show (:command result)))
(is (= "Home" (get-in result [:options :page-name])))))
(is (= "Home" (get-in result [:options :page-name]))))))
(deftest test-verb-subcommand-parse-graph-import-export
(testing "graph export parses with type and output"
(let [result (commands/parse-args ["graph" "export"
"--type" "edn"
@@ -349,8 +359,9 @@
"--input" "import.zip"
"--repo" "demo"])]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code])))))
(is (= :invalid-options (get-in result [:error :code]))))))
(deftest test-verb-subcommand-parse-flags
(testing "verb subcommands reject unknown flags"
(doseq [args [["list" "page" "--wat"]
["add" "block" "--wat"]
@@ -517,7 +528,7 @@
(assoc config :base-url "http://127.0.0.1:9999")))
(set! transport/invoke (fn [_ method direct-pass? args]
(swap! invoke-calls conj [method direct-pass? args])
(if (= method "thread-api/export-db-base64")
(if (= method :thread-api/export-db-base64)
"c3FsaXRl"
{:exported true})))
(set! transport/write-output (fn [opts]
@@ -538,8 +549,8 @@
{})]
(is (= :ok (:status edn-result)))
(is (= :ok (:status sqlite-result)))
(is (= [["thread-api/export-edn" false ["logseq_db_demo" {:export-type :graph}]]
["thread-api/export-db-base64" true ["logseq_db_demo"]]]
(is (= [[:thread-api/export-edn false ["logseq_db_demo" {:export-type :graph}]]
[:thread-api/export-db-base64 true ["logseq_db_demo"]]]
@invoke-calls))
(is (= 2 (count @write-calls)))
(let [[edn-write sqlite-write] @write-calls]
@@ -605,8 +616,8 @@
(is (= [[:edn "/tmp/import.edn"]
[:sqlite "/tmp/import.sqlite"]]
@read-calls))
(is (= [["thread-api/import-edn" ["logseq_db_demo" {:page "Import Page"}]]
["thread-api/import-db-base64" ["logseq_db_demo" "c3FsaXRl"]]]
(is (= [[:thread-api/import-edn ["logseq_db_demo" {:page "Import Page"}]]
[:thread-api/import-db-base64 ["logseq_db_demo" "c3FsaXRl"]]]
@invoke-calls))
(is (= ["logseq_db_demo" "logseq_db_demo"] @stop-calls))
(is (= ["logseq_db_demo" "logseq_db_demo"] @restart-calls)))

View File

@@ -193,5 +193,5 @@
:message "graph name is required"}}
{:output-format nil})]
(is (= (str "Error (missing-graph): graph name is required\n"
"Hint: Use --graph <name>")
"Hint: Use --repo <name>")
result)))))

View File

@@ -72,23 +72,46 @@
(is false (str "unexpected error: " e))
(done))))))
(deftest test-invoke-accepts-keyword-method
(async done
(let [received (atom nil)]
(-> (p/let [{:keys [url stop!]} (start-server
(fn [^js req ^js res]
(let [chunks (array)]
(.on req "data" (fn [chunk] (.push chunks chunk)))
(.on req "end" (fn []
(let [buf (js/Buffer.concat chunks)
payload (js/JSON.parse (.toString buf "utf8"))]
(reset! received (js->clj payload :keywordize-keys true))
(.writeHead res 200 #js {"Content-Type" "application/json"})
(.end res (js/JSON.stringify #js {:result "ok"}))))))))
result (transport/invoke {:base-url url} :thread-api/pull true ["repo" [:block/title]])]
(is (= "ok" result))
(is (= "thread-api/pull" (:method @received)))
(is (= true (:directPass @received)))
(p/let [_ (stop!)]
(done)))
(p/catch (fn [e]
(is false (str "unexpected error: " e))
(done)))))))
(deftest test-read-input
(testing "reads edn input"
(let [path (temp-path "input.edn")]
(.writeFileSync fs path "{:a 1}")
(is (= {:a 1} (transport/read-input {:format :edn :path path})))))
(let [file-path (temp-path "input.edn")]
(.writeFileSync fs file-path "{:a 1}")
(is (= {:a 1} (transport/read-input {:format :edn :path file-path})))))
(testing "reads sqlite input as buffer"
(let [path (temp-path "input.sqlite")
(let [file-path (temp-path "input.sqlite")
buffer (js/Buffer.from "sqlite-data")]
(.writeFileSync fs path buffer)
(let [result (transport/read-input {:format :sqlite :path path})]
(.writeFileSync fs file-path buffer)
(let [result (transport/read-input {:format :sqlite :path file-path})]
(is (instance? js/Buffer result))
(is (= "sqlite-data" (.toString result "utf8")))))))
(deftest test-write-output
(testing "writes sqlite output as buffer"
(let [path (temp-path "output.sqlite")
(let [file-path (temp-path "output.sqlite")
buffer (js/Buffer.from "sqlite-export")]
(transport/write-output {:format :sqlite :path path :data buffer})
(is (= "sqlite-export" (.toString (.readFileSync fs path) "utf8"))))))
(transport/write-output {:format :sqlite :path file-path :data buffer})
(is (= "sqlite-export" (.toString (.readFileSync fs file-path) "utf8"))))))

View File

@@ -1,93 +0,0 @@
(require '[babashka.curl :as curl]
'[cheshire.core :as json]
'[cognitect.transit :as transit]
'[clojure.pprint :as pprint]
'[clojure.string :as string])
(def base-url (or (System/getenv "DB_WORKER_URL") "http://127.0.0.1:9101"))
(defn write-transit [v]
(let [out (java.io.ByteArrayOutputStream.)
w (transit/writer out :json)]
(transit/write w v)
(.toString out "UTF-8")))
(defn read-transit [s]
(let [in (java.io.ByteArrayInputStream. (.getBytes s "UTF-8"))
r (transit/reader in :json)]
(transit/read r)))
(defn invoke [method direct-pass? args]
(let [payload (if direct-pass?
{:method method :directPass true :args args}
{:method method :directPass false :argsTransit (write-transit args)})
resp (curl/post (str base-url "/v1/invoke")
{:headers {"Content-Type" "application/json"}
:body (json/generate-string payload)})
body (json/parse-string (:body resp) true)]
(if (<= 200 (:status resp) 299)
(if direct-pass?
(:result body)
(read-transit (:resultTransit body)))
(throw (ex-info "db-worker invoke failed" {:status (:status resp) :body (:body resp)})))))
(def suffix (subs (str (random-uuid)) 0 8))
(def repo (str "logseq_db_smoke_" suffix))
(def page-uuid (random-uuid))
(def block-uuid (random-uuid))
(def now (long (System/currentTimeMillis)))
(println "== db-worker-node smoke test ==")
(println "Base URL:" base-url)
(println "Repo:" repo)
(println "Step 1/4: list-db (before)")
(println "Result:" (json/generate-string (invoke "thread-api/list-db" false [])
{:pretty true}))
(println "Step 2/4: create-or-open-db")
(invoke "thread-api/create-or-open-db" false [repo {}])
(println "Step 3/4: list-db (after)")
(println "Result:" (json/generate-string (invoke "thread-api/list-db" false [])
{:pretty true}))
(println "Step 4/4: transact + q")
(invoke "thread-api/transact" false
[repo
[{:block/uuid page-uuid
:block/title "Smoke Page"
:block/name "smoke-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}
{:block/uuid block-uuid
:block/title "Smoke Test"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]
:block/order "a0"
:block/created-at now
:block/updated-at now}]
{}
nil])
(let [query '[:find ?e
:in $ ?uuid
:where [?e :block/uuid ?uuid]]
result (invoke "thread-api/q" false [repo [query block-uuid]])]
(println "Query result:" result)
(when (empty? result)
(throw (ex-info "Query returned no results" {:uuid block-uuid}))))
(let [page-query '[:find (pull ?e [:db/id :block/uuid :block/title :block/name :block/tags])
:in $ ?uuid
:where [?e :block/uuid ?uuid]]
blocks-query '[:find (pull ?e [:db/id :block/uuid :block/title :block/order :block/parent])
:in $ ?page-uuid
:where [?page :block/uuid ?page-uuid]
[?e :block/page ?page]]
page-result (invoke "thread-api/q" false [repo [page-query page-uuid]])
blocks-result (invoke "thread-api/q" false [repo [blocks-query page-uuid]])]
(println "Page + blocks (pretty):")
(pprint/pprint {:page page-result
:blocks blocks-result}))
(println "Smoke test OK")

View File

@@ -1,53 +0,0 @@
#!/usr/bin/env bb
(require '[babashka.process :as process]
'[clojure.java.io :as io]
'[clojure.string :as string])
(def base-url (or (System/getenv "DB_WORKER_URL")
"http://127.0.0.1:9101"))
(def auth-token (System/getenv "DB_WORKER_AUTH_TOKEN"))
(def events-url (str (string/replace base-url #"/$" "") "/v1/events"))
(defn- open-sse-connection
[url token]
(let [^java.net.HttpURLConnection conn (.openConnection (java.net.URL. url))]
(.setRequestMethod conn "GET")
(.setRequestProperty conn "Accept" "text/event-stream")
(when (seq token)
(.setRequestProperty conn "Authorization" (str "Bearer " token)))
(.setDoInput conn true)
(.connect conn)
conn))
(defn- wait-for-sse!
[^java.net.HttpURLConnection conn timeout-ms]
(let [event-seen (promise)
reader (future
(try
(with-open [rdr (io/reader (.getInputStream conn))]
(doseq [line (line-seq rdr)]
(when (string/starts-with? line "data:")
(deliver event-seen line)
(reduced nil))))
(catch Exception _ nil)))]
(try
(let [result (deref event-seen timeout-ms ::timeout)]
(when (= result ::timeout)
(throw (ex-info "No SSE events captured" {:url events-url})))
result)
(finally
(.disconnect conn)
(future-cancel reader)))))
(defn- run-smoke-test!
[]
(let [{:keys [exit]} (process/shell {:inherit true}
"bb" "tmp_scripts/db-worker-smoke-test.clj")]
(when-not (zero? exit)
(throw (ex-info "Smoke test failed" {:exit exit})))))
(comment
(let [conn (open-sse-connection events-url auth-token)]
(run-smoke-test!)
(wait-for-sse! conn 2000)
(println "SSE smoke test OK")))