Просмотр исходного кода

003-db-worker-node-cli-orchestration.md (2)

rcmerci 2 дней назад
Родитель
Сommit
9287ef780f

+ 2 - 2
deps/cli/src/logseq/cli.cljs

@@ -109,7 +109,7 @@
     :args->opts [:args] :require [:args] :coerce {:args []}
     :spec cli-spec/append}
    {:cmds ["mcp-server"] :desc "Run a MCP server"
-    :description "Run a MCP server against a local graph if --graph is given or against the current in-app graph. For the in-app graph, the API server must be on in the app. By default the MCP server runs as a HTTP Streamable server. Use --stdio to run it as a stdio server."
+    :description "Run a MCP server against a local graph if --repo is given or against the current in-app graph. For the in-app graph, the API server must be on in the app. By default the MCP server runs as a HTTP Streamable server. Use --stdio to run it as a stdio server."
     :fn (lazy-load-fn 'logseq.cli.commands.mcp-server/start)
     :spec cli-spec/mcp-server}
    {:cmds ["validate"] :desc "Validate DB graph"
@@ -159,4 +159,4 @@
       (nbb.error/print-error-report e)
       (js/process.exit 1))))
 
-#js {:main -main}
+#js {:main -main}

+ 8 - 8
deps/db/src/logseq/db/sqlite/gc.cljs

@@ -57,12 +57,12 @@
         (println :debug :db-gc "There's no garbage data that's need to be collected.")))))
 
 (defn- get-unused-addresses-node-version
-  [db]
-  (let [schema (let [stmt (.prepare db "select content from kvs where addr = ?")
+  [^js db]
+  (let [schema (let [^js stmt (.prepare db "select content from kvs where addr = ?")
                      content (.-content (.get stmt 0))]
                  (sqlite-util/transit-read content))
         internal-addrs (set [0 1 (:eavt schema) (:avet schema) (:aevt schema)])
-        non-refed-addrs (let [stmt (.prepare db get-non-refed-addrs-sql)]
+        non-refed-addrs (let [^js stmt (.prepare db get-non-refed-addrs-sql)]
                           (->> (.all ^object stmt)
                                bean/->clj
                                (map :addr)
@@ -70,13 +70,13 @@
     (set/difference non-refed-addrs internal-addrs)))
 
 (defn- get-unused-addresses-node-walk-version
-  [db]
-  (let [schema (let [stmt (.prepare db "select content from kvs where addr = ?")
+  [^js db]
+  (let [schema (let [^js stmt (.prepare db "select content from kvs where addr = ?")
                      content (.-content (.get stmt 0))]
                  (sqlite-util/transit-read content))
         set-addresses #{(:eavt schema) (:avet schema) (:aevt schema)}
         internal-addresses (conj set-addresses 0 1)
-        parent->children (let [stmt (.prepare db "select addr, addresses from kvs")]
+        parent->children (let [^js stmt (.prepare db "select addr, addresses from kvs")]
                            (->> (.all ^object stmt)
                                 bean/->clj
                                 (map (fn [{:keys [addr addresses]}]
@@ -95,13 +95,13 @@
   (let [unused-addresses (if walk?
                            (get-unused-addresses-node-walk-version db)
                            (get-unused-addresses-node-version db))
-        addrs-count (let [stmt (.prepare db "select count(*) as c from kvs")]
+        addrs-count (let [^js stmt (.prepare db "select count(*) as c from kvs")]
                       (.-c (.get stmt)))]
     (println :debug "addrs total count: " addrs-count)
     (if (seq unused-addresses)
       (do
         (println :debug :db-gc :unused-addresses-count (count unused-addresses))
-        (let [stmt (.prepare db "Delete from kvs where addr = ?")
+        (let [^js stmt (.prepare db "Delete from kvs where addr = ?")
               delete (.transaction
                       db
                       (fn [addrs]

+ 4 - 4
docs/agent-guide/001-logseq-cli.md

@@ -28,19 +28,19 @@ NOTE: I will write *all* tests before I add any implementation behavior.
 ## Architecture sketch
 
 The CLI is a Node program that parses flags, loads config, and sends requests to db-worker-node.
-The db-worker-node server is already built from the :db-worker-node shadow-cljs target and listens on a TCP port.
+The db-worker-node server is already built from the :db-worker-node shadow-cljs target and listens on a random localhost TCP port recorded in the lock file.
 
 ASCII diagram:
 
 +--------------+        HTTP or WS        +---------------------+
 | logseq-cli   | -----------------------> | db-worker-node       |
-| node script  | <----------------------- | server on port 9101  |
+| node script  | <----------------------- | server on random port |
 +--------------+                          +---------------------+
 
 ## Assumptions
 
 The db-worker-node server exposes a stable API for a small set of requests needed by the CLI.
-The CLI will default to localhost:9101 unless configured otherwise.
+The CLI always uses localhost and discovers the server port from the lock file.
 The CLI will use JSON for request and response bodies for ease of scripting.
 
 ## Implementation plan
@@ -98,7 +98,7 @@ Open follow-ups (optional):
 
 ## Edge cases
 
-The db-worker-node server is not running or is listening on a different port.
+The db-worker-node server is not running or the lock file points to a stale server.
 The response payload is invalid JSON or missing fields.
 The request times out or the server closes the connection early.
 The user passes incompatible flags or unknown commands.

+ 8 - 11
docs/agent-guide/002-logseq-cli-subcommands.md

@@ -25,8 +25,8 @@ NOTE: I will write all tests before I add any implementation behavior.
 ## Architecture sketch
 
 +--------------+        HTTP        +---------------------+
-| logseq-cli   | -----------------> | db-worker-node       |
-| node script  | <----------------- | server on port 9101  |
+| logseq-cli   | -----------------> | db-worker-node        |
+| node script  | <----------------- | server on random port |
 +--------------+                    +---------------------+
 
 ## Command and output surface
@@ -56,9 +56,6 @@ Global options apply to all subcommands and are parsed before subcommand options
 | --- | --- | --- |
 | --help | Show help | Available at top level and per subcommand. |
 | --config PATH | Config file path | Defaults to ~/.logseq/cli.edn. |
-| --base-url URL | Server URL | Overrides host/port. |
-| --host HOST | Server host | Combined with --port. |
-| --port PORT | Server port | Combined with --host. |
 | --auth-token TOKEN | Auth token | Sent as header. |
 | --repo REPO | Graph name | Used as current repo. |
 | --timeout-ms MS | Request timeout | Integer milliseconds. |
@@ -70,11 +67,11 @@ Each subcommand uses a nested path and its own options.
 | Subcommand path | Required args | Options | Notes |
 | --- | --- | --- | --- |
 | graph list | none | --output | Lists all graphs. |
-| graph create | none | --graph GRAPH, --output | Creates and switches graph. |
-| graph switch | none | --graph GRAPH, --output | Switches current graph. |
-| graph remove | none | --graph GRAPH, --output | Removes graph. |
-| graph validate | none | --graph GRAPH, --output | Validates graph. |
-| graph info | none | --graph GRAPH, --output | Shows metadata, defaults to config repo if omitted. |
+| graph create | none | --repo GRAPH, --output | Creates and switches graph. |
+| graph switch | none | --repo GRAPH, --output | Switches current graph. |
+| graph remove | none | --repo GRAPH, --output | Removes graph. |
+| graph validate | none | --repo GRAPH, --output | Validates graph. |
+| graph info | none | --repo GRAPH, --output | Shows metadata, defaults to config repo if omitted. |
 | block add | none | --content TEXT, --blocks EDN, --blocks-file PATH, --page PAGE, --parent UUID, --output | Content source is required, with file and text variants. |
 | block remove | none | --block UUID, --page PAGE, --output | One of block or page is required. |
 | block search | none | --text TEXT, --limit N, --output | Search text is required. |
@@ -145,7 +142,7 @@ Expected output includes successful linting and tests with exit code 0.
 ## Testing Details
 
 The unit tests will exercise parsing and output formatting behavior without mocking internal parser details.
-The integration tests will start db-worker-node on a test port and invoke the CLI entrypoint with subcommands to verify end-to-end behavior.
+The integration tests will start db-worker-node on a random port and invoke the CLI entrypoint with subcommands to verify end-to-end behavior.
 
 ## Implementation Details
 

+ 6 - 6
docs/agent-guide/003-db-worker-node-cli-orchestration.md

@@ -5,7 +5,7 @@ Goal: Based on the current `logseq-cli` and `db-worker-node` implementations, re
 ## Background and Current State (from existing code)
 
 - `db-worker-node` currently accepts `--repo` but it is optional; it can open/switch graphs via `thread-api/create-or-open-db` at runtime. Entrypoint: `src/main/frontend/worker/db_worker_node.cljs`.
-- `logseq-cli` connects to an existing db-worker-node via `base-url`; it does not start/stop the server. Entrypoint: `src/main/logseq/cli/main.cljs`, `src/main/logseq/cli/commands.cljs`, `src/main/logseq/cli/transport.cljs`.
+- `logseq-cli` connects to an existing db-worker-node on localhost using the port recorded in the lock file; it does not start/stop the server. Entrypoint: `src/main/logseq/cli/main.cljs`, `src/main/logseq/cli/commands.cljs`, `src/main/logseq/cli/transport.cljs`.
 - Tests explicitly start db-worker-node (`src/test/logseq/cli/integration_test.cljs`, `src/test/frontend/worker/db_worker_node_test.cljs`).
 
 ## Requirements
@@ -62,12 +62,12 @@ Key changes:
 - **Repo resolution**: for all graph/content commands, require `--repo` or resolved repo from config; otherwise error.
 - **Ensure server** (new helper `ensure-server!`):
   1. Derive data-dir, repo dir, and lock file path from repo.
-  2. If lock file exists, read host/port/pid; probe `/healthz` + `/readyz`.
-  3. If healthy, reuse existing server; set `config :base-url` dynamically.
+  2. If lock file exists, read port/pid; probe `/healthz` + `/readyz`.
+  3. If healthy, reuse existing server; build the connection URL from localhost and the lock file port.
   4. If unhealthy or stale, attempt to spawn a new server; if db-worker-node cannot handle the lock situation, CLI repairs the lock then retries.
-  5. Spawn via `child_process.spawn`: `node ./static/db-worker-node.js --repo <repo> --data-dir <...> --host 127.0.0.1 --port 0`.
-  6. Resolve actual port from lock file or server output.
-- **base-url usage**: dynamically set based on repo server; no longer required from user. If `--base-url` is provided, decide if it is ignored or overrides orchestration (see Compatibility section).
+  5. Spawn via `child_process.spawn`: `node ./static/db-worker-node.js --repo <repo> --data-dir <...>`.
+  6. Resolve actual port from the lock file written by db-worker-node.
+- **Connection URL**: derived from the repo lock file; host is always localhost and the port is always discovered from the lock file.
 
 ### 3) CLI `server` subcommands
 

+ 1 - 2
docs/agent-guide/task--db-worker-nodejs-compatible.md

@@ -128,8 +128,7 @@ The db-worker should be runnable as a standalone process for Node.js environment
 ### Entry Point
 - Provide a CLI entry (example: `bin/logseq-db-worker` or `node dist/db-worker-node.js`).
 - CLI flags (suggested):
-  - `--host` (default `127.0.0.1`)
-  - `--port` (default `9101`)
+  - Binds to localhost on a random port and records it in the repo lock file.
   - `--data-dir` (path for sqlite files, required or default to `~/.logseq/db-worker`)
   - `--repo` (optional: auto-open a repo on boot)
   - `--rtc-ws-url` (optional)

+ 21 - 14
docs/cli/logseq-cli.md

@@ -1,6 +1,6 @@
 # Logseq CLI (Node)
 
-The Logseq CLI is a Node.js program compiled from ClojureScript that connects to the db-worker-node server.
+The Logseq CLI is a Node.js program compiled from ClojureScript that connects to a db-worker-node server managed by the CLI.
 
 ## Build the CLI
 
@@ -8,17 +8,14 @@ The Logseq CLI is a Node.js program compiled from ClojureScript that connects to
 clojure -M:cljs compile logseq-cli
 ```
 
-## Start db-worker-node (in another terminal)
+## db-worker-node lifecycle
 
-```bash
-clojure -M:cljs compile db-worker-node
-node ./static/db-worker-node.js
-```
+`logseq-cli` manages `db-worker-node` automatically. You should not start the server manually. The server binds to localhost on a random port and records that port in the repo lock file.
 
 ## Run the CLI
 
 ```bash
-node ./static/logseq-cli.js graph list --base-url http://127.0.0.1:9101
+node ./static/logseq-cli.js graph list
 ```
 
 ## Configuration
@@ -26,9 +23,9 @@ node ./static/logseq-cli.js graph list --base-url http://127.0.0.1:9101
 Optional configuration file: `~/.logseq/cli.edn`
 
 Supported keys include:
-- `:base-url`
 - `:auth-token`
 - `:repo`
+- `:data-dir`
 - `:timeout-ms`
 - `:retries`
 - `:output-format` (use `:json` or `:edn` for scripting)
@@ -39,11 +36,20 @@ CLI flags take precedence over environment variables, which take precedence over
 
 Graph commands:
 - `graph list` - list all db graphs
-- `graph create --graph <name>` - create a new db graph and switch to it
-- `graph switch --graph <name>` - switch current graph
-- `graph remove --graph <name>` - remove a graph
-- `graph validate --graph <name>` - validate graph data
-- `graph info [--graph <name>]` - show graph metadata (defaults to current graph)
+- `graph create --repo <name>` - create a new db graph and switch to it
+- `graph switch --repo <name>` - switch current graph
+- `graph remove --repo <name>` - remove a graph
+- `graph validate --repo <name>` - validate graph data
+- `graph info [--repo <name>]` - show graph metadata (defaults to current graph)
+
+For any command that requires `--repo`, if the target graph does not exist, the CLI returns `graph not exists` (except for `graph create`).
+
+Server commands:
+- `server list` - list running db-worker-node servers
+- `server status --repo <name>` - show server status for a graph
+- `server start --repo <name>` - start db-worker-node for a graph
+- `server stop --repo <name>` - stop db-worker-node for a graph
+- `server restart --repo <name>` - restart db-worker-node for a graph
 
 Block commands:
 - `block add --content <text> [--page <name>] [--parent <uuid>]` - add blocks; defaults to today’s journal page if no page is given
@@ -71,8 +77,9 @@ Output formats:
 Examples:
 
 ```bash
-node ./static/logseq-cli.js graph create --graph demo --base-url http://127.0.0.1:9101
+node ./static/logseq-cli.js graph create --repo demo
 node ./static/logseq-cli.js block add --page TestPage --content "hello world"
 node ./static/logseq-cli.js block search --text "hello"
 node ./static/logseq-cli.js block tree --page TestPage --format json --output json
+node ./static/logseq-cli.js server list
 ```

+ 2 - 2
src/main/frontend/persist_db/node.cljs

@@ -38,7 +38,7 @@
   [method url headers body]
   (p/create
    (fn [resolve reject]
-     (let [req (.request (request-module url)
+     (let [^js req (.request (request-module url)
                          #js {:method method
                               :hostname (.-hostname url)
                               :port (or (.-port url) (if (= "https:" (.-protocol url)) 443 80))
@@ -84,7 +84,7 @@
         headers (base-headers auth-token)
         buffer (atom "")
         handler (or event-handler (fn [_type _payload _wrapped-worker] nil))]
-    (let [req (.request
+    (let [^js req (.request
                (request-module url)
                #js {:method "GET"
                     :hostname (.-hostname url)

+ 126 - 58
src/main/frontend/worker/db_worker_node.cljs

@@ -3,6 +3,7 @@
   (:require ["http" :as http]
             [clojure.string :as string]
             [frontend.worker.db-core :as db-core]
+            [frontend.worker.db-worker-node-lock :as db-lock]
             [frontend.worker.platform.node :as platform-node]
             [frontend.worker.state :as worker-state]
             [goog.object :as gobj]
@@ -13,6 +14,7 @@
 
 (defonce ^:private *ready? (atom false))
 (defonce ^:private *sse-clients (atom #{}))
+(defonce ^:private *lock-info (atom nil))
 
 (defn- send-json!
   [^js res status payload]
@@ -50,8 +52,6 @@
       opts
       (let [[flag value & remaining] args]
         (case flag
-          "--host" (recur remaining (assoc opts :host value))
-          "--port" (recur remaining (assoc opts :port (js/parseInt value 10)))
           "--data-dir" (recur remaining (assoc opts :data-dir value))
           "--repo" (recur remaining (assoc opts :repo value))
           "--rtc-ws-url" (recur remaining (assoc opts :rtc-ws-url value))
@@ -68,8 +68,8 @@
 
 (defn- handle-event!
   [type payload]
-  (let [event (js/JSON.stringify (clj->js {:type type
-                                          :payload (encode-event-payload payload)}))
+  (let [event (js/JSON.stringify (clj->js {:type type}
+                                          :payload (encode-event-payload payload)))
         message (str "data: " event "\n\n")]
     (doseq [^js res @*sse-clients]
       (try
@@ -109,10 +109,38 @@
   [proxy rtc-ws-url]
   (<invoke! proxy "thread-api/init" true #js [rtc-ws-url]))
 
-(defn- <maybe-open-repo!
-  [proxy repo]
-  (when (seq repo)
-    (<invoke! proxy "thread-api/create-or-open-db" false [repo {}])))
+(def ^:private non-repo-methods
+  #{"thread-api/init"
+    "thread-api/list-db"
+    "thread-api/get-version"
+    "thread-api/set-infer-worker-proxy"})
+
+(defn- repo-arg
+  [args]
+  (cond
+    (js/Array.isArray args) (aget args 0)
+    (sequential? args) (first args)
+    :else nil))
+
+(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"}}
+
+        (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))))
 
 (defn- set-main-thread-stub!
   []
@@ -122,7 +150,7 @@
                                  {:method qkw})))))
 
 (defn- make-server
-  [proxy {:keys [auth-token]}]
+  [proxy {:keys [auth-token bound-repo stop-fn]}]
   (http/createServer
    (fn [^js req ^js res]
      (let [url (.-url req)
@@ -151,10 +179,17 @@
                          args' (if direct-pass?
                                  args
                                  (or argsTransit args))
-                         result (<invoke! proxy method direct-pass? args')]
-                   (send-json! res 200 (if direct-pass?
-                                         {:ok true :result result}
-                                         {:ok true :resultTransit result})))
+                         args-for-validation (if direct-pass?
+                                               args'
+                                               (if (string? args')
+                                                 (ldb/read-transit-str args')
+                                                 args'))]
+                   (if-let [{:keys [status error]} (repo-error method args-for-validation bound-repo)]
+                     (send-json! res status {:ok false :error error})
+                     (p/let [result (<invoke! proxy method direct-pass? args')]
+                       (send-json! res 200 (if direct-pass?
+                                             {:ok true :result result}
+                                             {:ok true :resultTransit result})))))
                  (p/catch (fn [e]
                             (log/error :db-worker-node-http-invoke-failed e)
                             (send-json! res 500 {:ok false
@@ -165,74 +200,107 @@
              (send-text! res 405 "method-not-allowed"))
            (send-text! res 401 "unauthorized"))
 
+         (= url "/v1/shutdown")
+         (if (authorized? req auth-token)
+           (if (= method "POST")
+             (do
+               (send-json! res 200 {:ok true})
+               (js/setTimeout (fn []
+                                (when stop-fn
+                                  (stop-fn)))
+                              10))
+             (send-text! res 405 "method-not-allowed"))
+           (send-text! res 401 "unauthorized"))
+
          :else
          (send-text! res 404 "not-found"))))))
 
 (defn- show-help!
   []
   (println "db-worker-node options:")
-  (println "  --host <host>        (default 127.0.0.1)")
-  (println "  --port <port>        (default 9101)")
   (println "  --data-dir <path>    (default ~/.logseq/db-worker)")
-  (println "  --repo <name>        (optional)")
+  (println "  --repo <name>        (required)")
   (println "  --rtc-ws-url <url>   (optional)")
   (println "  --log-level <level>  (default info)")
   (println "  --auth-token <token> (optional)"))
 
 (defn start-daemon!
-  [{:keys [host port data-dir repo rtc-ws-url auth-token]}]
-  (let [host (or host "127.0.0.1")
-        port (or port 9101)]
-    (reset! *ready? false)
-    (set-main-thread-stub!)
-    (p/let [platform (platform-node/node-platform {:data-dir data-dir
-                                                   :event-fn handle-event!})
-            proxy (db-core/init-core! platform)
-            _ (<init-worker! proxy (or rtc-ws-url ""))]
-      (reset! *ready? true)
-      (p/do!
-       (<maybe-open-repo! proxy repo)
-       (let [server (make-server proxy {:auth-token auth-token})]
-         (p/create
-          (fn [resolve reject]
-            (.listen server port host
-                     (fn []
-                       (let [address (.address server)
-                             actual-port (if (number? address)
-                                           address
-                                           (.-port address))
-                             stop! (fn []
-                                     (p/create
-                                      (fn [resolve _]
-                                        (reset! *ready? false)
-                                        (doseq [^js res @*sse-clients]
-                                          (try
-                                            (.end res)
-                                            (catch :default _)))
-                                        (reset! *sse-clients #{})
-                                        (.close server (fn [] (resolve true))))))]
-                         (resolve {:host host
-                                   :port actual-port
-                                   :server server
-                                   :stop! stop!}))))
-            (.on server "error" reject))))))))
+  [{:keys [data-dir repo rtc-ws-url auth-token]}]
+  (let [host "127.0.0.1"
+        port 0]
+    (if-not (seq repo)
+      (p/rejected (ex-info "repo is required" {:code :missing-repo}))
+      (do
+        (reset! *ready? false)
+        (set-main-thread-stub!)
+        (-> (p/let [platform (platform-node/node-platform {:data-dir data-dir
+                                                           :event-fn handle-event!})
+                    proxy (db-core/init-core! platform)
+                    _ (<init-worker! proxy (or rtc-ws-url ""))
+                    {:keys [path lock]} (db-lock/ensure-lock! {:data-dir data-dir
+                                                               :repo repo
+                                                               :host host
+                                                               :port port})
+                    _ (reset! *lock-info {:path path :lock lock})
+                    _ (<invoke! proxy "thread-api/create-or-open-db" false [repo {}])]
+              (let [stop!* (atom nil)
+                    server (make-server proxy {:auth-token auth-token
+                                               :bound-repo repo
+                                               :stop-fn (fn []
+                                                          (when-let [stop! @stop!*]
+                                                            (stop!)))})]
+                (p/create
+                 (fn [resolve reject]
+                   (.listen server port host
+                            (fn []
+                              (let [address (.address server)
+                                    actual-port (if (number? address)
+                                                  address
+                                                  (.-port address))
+                                    stop! (fn []
+                                            (p/create
+                                             (fn [resolve _]
+                                               (reset! *ready? false)
+                                               (doseq [^js res @*sse-clients]
+                                                 (try
+                                                   (.end res)
+                                                   (catch :default _)))
+                                               (reset! *sse-clients #{})
+                                               (when-let [lock-path (:path @*lock-info)]
+                                                 (db-lock/remove-lock! lock-path))
+                                               (.close server (fn [] (resolve true))))))]
+                                (reset! *ready? true)
+                                (reset! stop!* stop!)
+                                (p/let [lock' (assoc (:lock @*lock-info) :port actual-port)
+                                        _ (db-lock/update-lock! (:path @*lock-info) lock')]
+                                  (resolve {:host host
+                                            :port actual-port
+                                            :server server
+                                            :stop! stop!})))))
+                   (.on server "error" (fn [error]
+                                         (when-let [lock-path (:path @*lock-info)]
+                                           (db-lock/remove-lock! lock-path))
+                                         (reject error)))))))
+            (p/catch (fn [e]
+                       (when-let [lock-path (:path @*lock-info)]
+                         (db-lock/remove-lock! lock-path))
+                       (throw e))))))))
 
 (defn main
   []
-  (let [{:keys [host port data-dir repo rtc-ws-url log-level auth-token help?]}
+  (let [{:keys [data-dir repo rtc-ws-url log-level auth-token help?]}
         (parse-args (.-argv js/process))
-        host (or host "127.0.0.1")
-        port (or port 9101)
         log-level (keyword (or log-level "info"))]
     (when help?
       (show-help!)
       (.exit js/process 0))
+    (when-not (seq repo)
+      (show-help!)
+      (.exit js/process 1))
     (glogi-console/install!)
     (log/set-levels {:glogi/root log-level})
     (p/let [{:keys [stop!] :as daemon}
-            (start-daemon! {:host host
-                            :port port
-                            :data-dir data-dir
+            (start-daemon! {:data-dir data-dir
                             :repo repo
                             :rtc-ws-url rtc-ws-url
                             :auth-token auth-token})]

+ 97 - 0
src/main/frontend/worker/db_worker_node_lock.cljs

@@ -0,0 +1,97 @@
+(ns frontend.worker.db-worker-node-lock
+  "Lock file helpers for db-worker-node."
+  (:require ["fs" :as fs]
+            ["os" :as os]
+            ["path" :as node-path]
+            [clojure.string :as string]
+            [frontend.worker-common.util :as worker-util]
+            [lambdaisland.glogi :as log]
+            [promesa.core :as p]))
+
+(defn- expand-home
+  [path]
+  (if (string/starts-with? path "~")
+    (node-path/join (.homedir os) (subs path 1))
+    path))
+
+(defn resolve-data-dir
+  [data-dir]
+  (expand-home (or data-dir "~/.logseq/db-worker")))
+
+(defn repo-dir
+  [data-dir repo]
+  (let [pool-name (worker-util/get-pool-name repo)]
+    (node-path/join data-dir (str "." pool-name))))
+
+(defn lock-path
+  [data-dir repo]
+  (node-path/join (repo-dir data-dir repo) "db-worker.lock"))
+
+(defn- pid-alive?
+  [pid]
+  (when (number? pid)
+    (try
+      (.kill js/process pid 0)
+      true
+      (catch :default _ false))))
+
+(defn read-lock
+  [path]
+  (when (and (seq path) (fs/existsSync path))
+    (js->clj (js/JSON.parse (.toString (fs/readFileSync path) "utf8"))
+             :keywordize-keys true)))
+
+(defn remove-lock!
+  [path]
+  (when (and (seq path) (fs/existsSync path))
+    (fs/unlinkSync path)))
+
+(defn create-lock!
+  [{:keys [data-dir repo host port]}]
+  (p/create
+   (fn [resolve reject]
+     (try
+       (let [data-dir (resolve-data-dir data-dir)
+             path (lock-path data-dir repo)
+             existing (read-lock path)]
+         (when (and existing (pid-alive? (:pid existing)))
+           (throw (ex-info "graph already locked" {:code :repo-locked :lock existing})))
+         (when existing
+           (remove-lock! path))
+         (fs/mkdirSync (node-path/dirname path) #js {:recursive true})
+         (let [fd (fs/openSync path "wx")
+               lock {:repo repo
+                     :pid (.-pid js/process)
+                     :host host
+                     :port port
+                     :startedAt (.toISOString (js/Date.))}]
+           (try
+             (fs/writeFileSync fd (js/JSON.stringify (clj->js lock)))
+             (finally
+               (fs/closeSync fd)))
+           (resolve lock)))
+       (catch :default e
+         (log/error :db-worker-node-lock-create-failed e)
+         (reject e))))))
+
+(defn update-lock!
+  [path lock]
+  (p/create
+   (fn [resolve reject]
+     (try
+       (fs/writeFileSync path (js/JSON.stringify (clj->js lock)))
+       (resolve lock)
+       (catch :default e
+         (log/error :db-worker-node-lock-update-failed e)
+         (reject e))))))
+
+(defn ensure-lock!
+  [{:keys [data-dir repo host port]}]
+  (let [data-dir (resolve-data-dir data-dir)
+        path (lock-path data-dir repo)]
+    (p/let [lock (create-lock! {:data-dir data-dir
+                                :repo repo
+                                :host host
+                                :port port})]
+      {:path path
+       :lock lock})))

+ 1 - 1
src/main/frontend/worker/platform/browser.cljs

@@ -98,7 +98,7 @@
 (defn- open-sqlite-db
   [{:keys [sqlite pool path mode]}]
   (if pool
-    (new (.-OpfsSAHPoolDb pool) path)
+    (new (.-OpfsSAHPoolDb ^js pool) path)
     (let [^js DB (.-DB ^js (.-oo1 ^js sqlite))]
       (new DB path (or mode "c")))))
 

+ 2 - 2
src/main/frontend/worker/platform/node.cljs

@@ -77,7 +77,7 @@
         (p/catch (fn [_] false)))))
 
 (defn- exec-sql
-  [db opts-or-sql]
+  [^js db opts-or-sql]
   (if (string? opts-or-sql)
     (.exec db opts-or-sql)
     (let [sql (gobj/get opts-or-sql "sql")
@@ -94,7 +94,7 @@
                         (gobj/set out normalized value)))
                     out)
                   bind)
-          stmt (.prepare db sql)]
+          ^js stmt (.prepare db sql)]
       (if (= row-mode "array")
         (do
           (.raw stmt)

+ 237 - 95
src/main/logseq/cli/commands.cljs

@@ -6,6 +6,7 @@
             [cljs.reader :as reader]
             [clojure.string :as string]
             [logseq.cli.config :as cli-config]
+            [logseq.cli.server :as cli-server]
             [logseq.cli.transport :as transport]
             [logseq.common.config :as common-config]
             [logseq.common.util :as common-util]
@@ -17,20 +18,17 @@
           :desc "Show help"
           :coerce :boolean}
    :config {:desc "Path to cli.edn"}
-   :base-url {:desc "Base URL for db-worker-node"}
-   :host {:desc "Host for db-worker-node"}
-   :port {:desc "Port for db-worker-node"
-          :coerce :long}
    :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)"}})
 
-(def ^:private graph-spec
-  {:graph {:desc "Graph name"}})
+(def ^:private server-spec
+  {:repo {:desc "Graph name"}})
 
 (def ^:private content-add-spec
   {:content {:desc "Block content for add"}
@@ -126,6 +124,13 @@
            :message "graph name is required"}
    :summary summary})
 
+(defn- missing-repo-result
+  [summary]
+  {:ok? false
+   :error {:code :missing-repo
+           :message "repo is required"}
+   :summary summary})
+
 (defn- missing-content-result
   [summary]
   {:ok? false
@@ -183,11 +188,16 @@
 
 (def ^:private table
   [(command-entry ["graph" "list"] :graph-list "List graphs" {})
-   (command-entry ["graph" "create"] :graph-create "Create graph" graph-spec)
-   (command-entry ["graph" "switch"] :graph-switch "Switch current graph" graph-spec)
-   (command-entry ["graph" "remove"] :graph-remove "Remove graph" graph-spec)
-   (command-entry ["graph" "validate"] :graph-validate "Validate graph" graph-spec)
-   (command-entry ["graph" "info"] :graph-info "Graph metadata" graph-spec)
+   (command-entry ["graph" "create"] :graph-create "Create graph" {})
+   (command-entry ["graph" "switch"] :graph-switch "Switch current graph" {})
+   (command-entry ["graph" "remove"] :graph-remove "Remove graph" {})
+   (command-entry ["graph" "validate"] :graph-validate "Validate graph" {})
+   (command-entry ["graph" "info"] :graph-info "Graph metadata" {})
+   (command-entry ["server" "list"] :server-list "List db-worker-node servers" {})
+   (command-entry ["server" "status"] :server-status "Show server status for a graph" server-spec)
+   (command-entry ["server" "start"] :server-start "Start db-worker-node for a graph" server-spec)
+   (command-entry ["server" "stop"] :server-stop "Stop db-worker-node for a graph" server-spec)
+   (command-entry ["server" "restart"] :server-restart "Restart db-worker-node for a graph" server-spec)
    (command-entry ["block" "add"] :add "Add blocks" content-add-spec)
    (command-entry ["block" "remove"] :remove "Remove block or page" content-remove-spec)
    (command-entry ["block" "search"] :search "Search blocks" content-search-spec)
@@ -243,7 +253,7 @@
   (let [opts (normalize-opts opts)
         args (vec args)
         cmd-summary (command-summary {:cmds cmds :spec spec})
-        graph (or (:graph opts) (:repo opts))
+        graph (:repo opts)
         has-args? (seq args)
         has-content? (or (seq (:content opts))
                          (seq (:blocks opts))
@@ -269,6 +279,10 @@
       (and (= command :search) (not (or (seq (:text opts)) has-args?)))
       (missing-search-result summary)
 
+      (and (#{:server-status :server-start :server-stop :server-restart} command)
+           (not (seq (:repo opts))))
+      (missing-repo-result summary)
+
       :else
       (ok-result command opts args summary))))
 
@@ -287,7 +301,7 @@
          :error {:code :missing-command
                  :message "missing command"}
          :summary summary})
-      (if (and (= 1 (count args)) (#{"graph" "block"} (first args)))
+      (if (and (= 1 (count args)) (#{"graph" "block" "server"} (first args)))
         (help-result (group-summary (first args) table))
         (try
           (let [result (cli/dispatch table args {:spec global-spec})]
@@ -326,10 +340,21 @@
   (when (seq repo)
     (string/replace-first repo common-config/db-version-prefix "")))
 
+(defn- ensure-existing-graph
+  [action config]
+  (if (and (:repo action) (not (:allow-missing-graph action)))
+    (p/let [graphs (cli-server/list-graphs config)
+            graph (repo->graph (:repo action))]
+      (if (some #(= graph %) graphs)
+        {:ok? true}
+        {:ok? false
+         :error {:code :graph-not-exists
+                 :message "graph not exists"}}))
+    (p/resolved {:ok? true})))
+
 (defn- pick-graph
   [options command-args config]
-  (or (:graph options)
-      (:repo options)
+  (or (:repo options)
       (first command-args)
       (:repo config)))
 
@@ -501,10 +526,7 @@
   (case command
     :graph-list
     {:ok? true
-     :action {:type :invoke
-              :method "thread-api/list-db"
-              :direct-pass? false
-              :args []}}
+     :action {:type :graph-list}}
 
     :graph-create
     (if-not (seq graph)
@@ -514,6 +536,8 @@
                 :method "thread-api/create-or-open-db"
                 :direct-pass? false
                 :args [repo {}]
+                :repo repo
+                :allow-missing-graph true
                 :persist-repo (repo->graph repo)}})
 
     :graph-switch
@@ -531,7 +555,8 @@
        :action {:type :invoke
                 :method "thread-api/unsafe-unlink-db"
                 :direct-pass? false
-                :args [repo]}})
+                :args [repo]
+                :repo repo}})
 
     :graph-validate
     (if-not (seq repo)
@@ -540,7 +565,8 @@
        :action {:type :invoke
                 :method "thread-api/validate-db"
                 :direct-pass? false
-                :args [repo]}})
+                :args [repo]
+                :repo repo}})
 
     :graph-info
     (if-not (seq repo)
@@ -550,6 +576,45 @@
                 :repo repo
                 :graph (repo->graph repo)}})))
 
+(defn- build-server-action
+  [command repo]
+  (case command
+    :server-list
+    {:ok? true
+     :action {:type :server-list}}
+
+    :server-status
+    (if-not (seq repo)
+      (missing-repo-error "repo is required for server status")
+      {:ok? true
+       :action {:type :server-status
+                :repo repo}})
+
+    :server-start
+    (if-not (seq repo)
+      (missing-repo-error "repo is required for server start")
+      {:ok? true
+       :action {:type :server-start
+                :repo repo}})
+
+    :server-stop
+    (if-not (seq repo)
+      (missing-repo-error "repo is required for server stop")
+      {:ok? true
+       :action {:type :server-stop
+                :repo repo}})
+
+    :server-restart
+    (if-not (seq repo)
+      (missing-repo-error "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- build-add-action
   [options args repo]
   (if-not (seq repo)
@@ -623,11 +688,15 @@
     parsed
     (let [{:keys [command options args]} parsed
           graph (pick-graph options args config)
-          repo (resolve-repo graph)]
+          repo (resolve-repo graph)
+          server-repo (resolve-repo (:repo options))]
       (case command
         (:graph-list :graph-create :graph-switch :graph-remove :graph-validate :graph-info)
         (build-graph-action command graph repo)
 
+        (:server-list :server-status :server-start :server-stop :server-restart)
+        (build-server-action command server-repo)
+
         :add
         (build-add-action options args repo)
 
@@ -644,87 +713,160 @@
          :error {:code :unknown-command
                  :message (str "unknown command: " command)}}))))
 
-(defn execute
+(defn- execute-graph-list
+  [_action config]
+  (let [graphs (cli-server/list-graphs config)]
+    {:status :ok
+     :data {:graphs graphs}}))
+
+(defn- execute-invoke
   [action config]
-  (case (:type action)
-    :invoke
-    (-> (p/let [result (transport/invoke config
-                                         (: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}})))
+  (-> (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}}))))
 
-    :graph-switch
-    (-> (p/let [exists? (transport/invoke config "thread-api/db-exists" false [(:repo action)])]
-          (if-not exists?
-            {:status :error
-             :error {:code :graph-not-found
-                     :message (str "graph not found: " (:graph action))}}
-            (p/let [_ (transport/invoke config "thread-api/create-or-open-db" false [(:repo action) {}])]
-              (cli-config/update-config! config {:repo (:graph action)})
-              {:status :ok
-               :data {:message (str "switched to " (:graph action))}}))))
+(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)}})))))
 
-    :graph-info
-    (-> (p/let [created (transport/invoke config "thread-api/pull" false [(:repo action) [:kv/value] :logseq.kv/graph-created-at])
-                schema (transport/invoke config "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)}}))
-
-    :add
-    (-> (p/let [target-id (resolve-add-target config action)
-                ops [[:insert-blocks [(:blocks action)
-                                      target-id
-                                      {:sibling? false
-                                       :bottom? true
-                                       :outliner-op :insert-blocks}]]]
-                result (transport/invoke config "thread-api/apply-outliner-ops" false [(:repo action) ops {}])]
+(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-add
+  [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-remove
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              result (perform-remove cfg action)]
+        {:status :ok
+         :data {:result result}})))
+
+(defn- execute-search
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              query '[:find ?e ?title
+                      :in $ ?q
+                      :where
+                      [?e :block/title ?title]
+                      [(clojure.string/includes? ?title ?q)]]
+              results (transport/invoke cfg "thread-api/q" false [(:repo action) [query (:text action)]])
+              mapped (mapv (fn [[id title]] {:db/id id :block/title title}) results)
+              limited (if (some? (:limit action)) (vec (take (:limit action) mapped)) mapped)]
+        {:status :ok
+         :data {:results limited}})))
+
+(defn- execute-tree
+  [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 {:result result}}))
+           :data tree-data
+           :output-format :edn}
 
-    :remove
-    (-> (p/let [result (perform-remove config action)]
+          "json"
           {:status :ok
-           :data {:result result}}))
-
-    :search
-    (-> (p/let [query '[:find ?e ?title
-                        :in $ ?q
-                        :where
-                        [?e :block/title ?title]
-                        [(clojure.string/includes? ?title ?q)]]
-                results (transport/invoke config "thread-api/q" false [(:repo action) [query (:text action)]])
-                mapped (mapv (fn [[id title]] {:db/id id :block/title title}) results)
-                limited (if (some? (:limit action)) (vec (take (:limit action) mapped)) mapped)]
+           :data tree-data
+           :output-format :json}
+
           {:status :ok
-           :data {:results limited}}))
+           :data {:message (tree->text tree-data)}}))))
 
-    :tree
-    (-> (p/let [tree-data (fetch-tree config action)
-                format (:format action)]
-          (case format
-            "edn"
-            {:status :ok
-             :data tree-data
-             :output-format :edn}
+(defn- server-result->response
+  [result]
+  (if (:ok? result)
+    {:status :ok
+     :data (:data result)}
+    {:status :error
+     :error (:error result)}))
 
-            "json"
-            {:status :ok
-             :data tree-data
-             :output-format :json}
+(defn- execute-server-list
+  [_action config]
+  (-> (p/let [servers (cli-server/list-servers config)]
+        {:status :ok
+         :data {:servers servers}})))
 
-            {:status :ok
-             :data {:message (tree->text tree-data)}})))
+(defn- execute-server-status
+  [action config]
+  (-> (p/let [result (cli-server/server-status config (:repo action))]
+        (server-result->response result))))
 
-    {:status :error
-     :error {:code :unknown-action
-             :message "unknown action"}}))
+(defn- execute-server-start
+  [action config]
+  (-> (p/let [result (cli-server/start-server! config (:repo action))]
+        (server-result->response result))))
+
+(defn- execute-server-stop
+  [action config]
+  (-> (p/let [result (cli-server/stop-server! config (:repo action))]
+        (server-result->response result))))
+
+(defn- execute-server-restart
+  [action config]
+  (-> (p/let [result (cli-server/restart-server! config (:repo action))]
+        (server-result->response result))))
+
+(defn execute
+  [action config]
+  (-> (p/let [check (ensure-existing-graph action config)]
+        (if-not (:ok? check)
+          {:status :error
+           :error (:error check)}
+          (case (:type action)
+            :graph-list (execute-graph-list action config)
+            :invoke (execute-invoke action config)
+            :graph-switch (execute-graph-switch action config)
+            :graph-info (execute-graph-info action config)
+            :add (execute-add action config)
+            :remove (execute-remove action config)
+            :search (execute-search action config)
+            :tree (execute-tree action config)
+            :server-list (execute-server-list action config)
+            :server-status (execute-server-status action config)
+            :server-start (execute-server-start action config)
+            :server-stop (execute-server-stop action config)
+            :server-restart (execute-server-restart action config)
+            {:status :error
+             :error {:code :unknown-action
+                     :message "unknown action"}})))))

+ 7 - 14
src/main/logseq/cli/config.cljs

@@ -54,15 +54,15 @@
   []
   (let [env (.-env js/process)]
     (cond-> {}
-      (seq (gobj/get env "LOGSEQ_DB_WORKER_URL"))
-      (assoc :base-url (gobj/get env "LOGSEQ_DB_WORKER_URL"))
-
       (seq (gobj/get env "LOGSEQ_DB_WORKER_AUTH_TOKEN"))
       (assoc :auth-token (gobj/get env "LOGSEQ_DB_WORKER_AUTH_TOKEN"))
 
       (seq (gobj/get env "LOGSEQ_CLI_REPO"))
       (assoc :repo (gobj/get env "LOGSEQ_CLI_REPO"))
 
+      (seq (gobj/get env "LOGSEQ_CLI_DATA_DIR"))
+      (assoc :data-dir (gobj/get env "LOGSEQ_CLI_DATA_DIR"))
+
       (seq (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS"))
       (assoc :timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS")))
 
@@ -75,17 +75,12 @@
       (seq (gobj/get env "LOGSEQ_CLI_CONFIG"))
       (assoc :config-path (gobj/get env "LOGSEQ_CLI_CONFIG")))))
 
-(defn- build-base-url
-  [{:keys [host port]}]
-  (when (or (seq host) (some? port))
-    (str "http://" (or host "127.0.0.1") ":" (or port 9101))))
-
 (defn resolve-config
   [opts]
-  (let [defaults {:base-url "http://127.0.0.1:9101"
-                  :timeout-ms 10000
+  (let [defaults {:timeout-ms 10000
                   :retries 0
                   :output-format nil
+                  :data-dir "~/.logseq/db-worker"
                   :config-path (default-config-path)}
         env (env-config)
         config-path (or (:config-path opts)
@@ -98,8 +93,6 @@
                           (parse-output-format (:output env))
                           (parse-output-format (:output-format file-config))
                           (parse-output-format (:output file-config)))
-        merged (merge defaults file-config env opts {:config-path config-path})
-        derived (build-base-url merged)]
+        merged (merge defaults file-config env opts {:config-path config-path})]
     (cond-> merged
-      output-format (assoc :output-format output-format)
-      (seq derived) (assoc :base-url derived))))
+      output-format (assoc :output-format output-format))))

+ 1 - 1
src/main/logseq/cli/main.cljs

@@ -12,7 +12,7 @@
   (string/join "\n"
                ["logseq-cli <command> [options]"
                 ""
-                "Commands: graph list, graph create, graph switch, graph remove, graph validate, graph info, block add, block remove, block search, block tree"
+                "Commands: graph list, graph create, graph switch, graph remove, graph validate, graph info, server list, server status, server start, server stop, server restart, block add, block remove, block search, block tree"
                 ""
                 "Options:"
                 summary]))

+ 321 - 0
src/main/logseq/cli/server.cljs

@@ -0,0 +1,321 @@
+(ns logseq.cli.server
+  "db-worker-node lifecycle orchestration for logseq-cli."
+  (:require ["child_process" :as child-process]
+            ["fs" :as fs]
+            ["http" :as http]
+            ["os" :as os]
+            ["path" :as node-path]
+            [clojure.string :as string]
+            [frontend.worker.db-worker-node :as db-worker-node]
+            [frontend.worker-common.util :as worker-util]
+            [lambdaisland.glogi :as log]
+            [promesa.core :as p]))
+
+(defonce ^:private *inproc-servers (atom {}))
+
+(defn- inproc-enabled?
+  []
+  (boolean (.-DEBUG js/goog)))
+
+(defn- expand-home
+  [path]
+  (if (string/starts-with? path "~")
+    (node-path/join (.homedir os) (subs path 1))
+    path))
+
+(defn resolve-data-dir
+  [config]
+  (expand-home (or (:data-dir config) "~/.logseq/db-worker")))
+
+(defn- repo-dir
+  [data-dir repo]
+  (let [pool-name (worker-util/get-pool-name repo)]
+    (node-path/join data-dir (str "." pool-name))))
+
+(defn lock-path
+  [data-dir repo]
+  (node-path/join (repo-dir data-dir repo) "db-worker.lock"))
+
+(defn- pid-alive?
+  [pid]
+  (when (number? pid)
+    (try
+      (.kill js/process pid 0)
+      true
+      (catch :default _ false))))
+
+(defn- read-lock
+  [path]
+  (when (and (seq path) (fs/existsSync path))
+    (js->clj (js/JSON.parse (.toString (fs/readFileSync path) "utf8"))
+             :keywordize-keys true)))
+
+(defn- remove-lock!
+  [path]
+  (when (and (seq path) (fs/existsSync path))
+    (fs/unlinkSync path)))
+
+(defn- base-url
+  [{:keys [host port]}]
+  (str "http://" host ":" port))
+
+(defn- http-request
+  [{:keys [method host port path headers body timeout-ms]}]
+  (p/create
+   (fn [resolve reject]
+     (let [timeout-ms (or timeout-ms 5000)
+           req (.request
+                http
+                #js {:method method
+                     :hostname host
+                     :port port
+                     :path path
+                     :headers (clj->js (or headers {}))}
+                (fn [^js res]
+                  (let [chunks (array)]
+                    (.on res "data" (fn [chunk] (.push chunks chunk)))
+                    (.on res "end" (fn []
+                                     (let [buf (js/Buffer.concat chunks)]
+                                       (resolve {:status (.-statusCode res)
+                                                 :body (.toString buf "utf8")}))))
+                    (.on res "error" reject))))
+           timeout-id (js/setTimeout
+                       (fn []
+                         (.destroy req)
+                         (reject (ex-info "request timeout" {:code :timeout})))
+                       timeout-ms)]
+       (.on req "error" (fn [err]
+                          (js/clearTimeout timeout-id)
+                          (reject err)))
+       (when body
+         (.write req body))
+       (.end req)
+       (.on req "response" (fn [_]
+                             (js/clearTimeout timeout-id)))))))
+
+(defn- ready?
+  [{:keys [host port]}]
+  (-> (p/let [{:keys [status]} (http-request {:method "GET"
+                                              :host host
+                                              :port port
+                                              :path "/readyz"
+                                              :timeout-ms 1000})]
+        (= 200 status))
+      (p/catch (fn [_] false))))
+
+(defn- healthy?
+  [{:keys [host port]}]
+  (-> (p/let [{:keys [status]} (http-request {:method "GET"
+                                              :host host
+                                              :port port
+                                              :path "/healthz"
+                                              :timeout-ms 1000})]
+        (= 200 status))
+      (p/catch (fn [_] false))))
+
+(defn- valid-lock?
+  [lock]
+  (and (seq (:host lock))
+       (pos-int? (:port lock))))
+
+(defn- cleanup-stale-lock!
+  [path lock]
+  (cond
+    (nil? lock)
+    (p/resolved nil)
+
+    (not (pid-alive? (:pid lock)))
+    (do
+      (remove-lock! path)
+      (p/resolved nil))
+
+    (not (valid-lock? lock))
+    (do
+      (remove-lock! path)
+      (p/resolved nil))
+
+    :else
+    (p/let [healthy (healthy? lock)]
+      (when-not healthy
+        (remove-lock! path)))))
+
+(defn- wait-for
+  [pred-fn {:keys [timeout-ms interval-ms]
+            :or {timeout-ms 8000
+                 interval-ms 200}}]
+  (p/create
+   (fn [resolve reject]
+     (let [start (js/Date.now)
+           tick (fn tick []
+                  (p/let [ok? (pred-fn)]
+                    (if ok?
+                      (resolve true)
+                      (if (> (- (js/Date.now) start) timeout-ms)
+                        (reject (ex-info "timeout" {:code :timeout}))
+                        (js/setTimeout tick interval-ms)))))]
+       (tick)))))
+
+(defn- wait-for-lock
+  [path]
+  (wait-for (fn []
+              (p/resolved (and (fs/existsSync path)
+                               (let [lock (read-lock path)]
+                                 (pos-int? (:port lock))))))
+            {:timeout-ms 8000
+             :interval-ms 200}))
+
+(defn- wait-for-ready
+  [lock]
+  (wait-for (fn [] (ready? lock))
+            {:timeout-ms 8000
+             :interval-ms 250}))
+
+(defn- spawn-server!
+  [{:keys [repo data-dir]}]
+  (let [script (node-path/join (js/process.cwd) "static" "db-worker-node.js")
+        args #js [script "--repo" repo "--data-dir" data-dir]
+        child (.spawn child-process "node" args #js {:detached true
+                                                     :stdio "ignore"})]
+    (.unref child)
+    child))
+
+(defn- start-inproc-server!
+  [{:keys [repo data-dir]}]
+  (p/let [daemon (db-worker-node/start-daemon! {:data-dir data-dir
+                                                :repo repo})]
+    (swap! *inproc-servers assoc repo daemon)
+    daemon))
+
+(defn- ensure-server-started!
+  [config repo]
+  (let [data-dir (resolve-data-dir config)
+        path (lock-path data-dir repo)]
+    (p/let [existing (read-lock path)
+            _ (cleanup-stale-lock! path existing)
+            _ (when (not (fs/existsSync path))
+                (if (inproc-enabled?)
+                  (start-inproc-server! {:repo repo :data-dir data-dir})
+                  (spawn-server! {:repo repo :data-dir data-dir}))
+                (wait-for-lock path))
+            lock (read-lock path)]
+      (when-not lock
+        (throw (ex-info "db-worker-node failed to start" {:code :server-start-failed})))
+      (p/let [_ (wait-for-ready lock)]
+        lock))))
+
+(defn ensure-server!
+  [config repo]
+  (p/let [lock (ensure-server-started! config repo)]
+    (assoc config :base-url (base-url lock))))
+
+(defn- shutdown!
+  [{:keys [host port]}]
+  (p/let [{:keys [status]} (http-request {:method "POST"
+                                          :host host
+                                          :port port
+                                          :path "/v1/shutdown"
+                                          :headers {"Content-Type" "application/json"}
+                                          :timeout-ms 1000})]
+    (= 200 status)))
+
+(defn stop-server!
+  [config repo]
+  (let [data-dir (resolve-data-dir config)
+        path (lock-path data-dir repo)
+        lock (read-lock path)]
+    (if-not lock
+      (p/resolved {:ok? false
+                   :error {:code :server-not-found
+                           :message "server is not running"}})
+      (-> (p/let [_ (shutdown! lock)]
+            (wait-for (fn []
+                        (p/resolved (not (fs/existsSync path))))
+                      {:timeout-ms 5000
+                       :interval-ms 200})
+            (swap! *inproc-servers dissoc repo)
+            {:ok? true
+             :data {:repo repo}})
+          (p/catch (fn [_]
+                     (when (and (pid-alive? (:pid lock))
+                                (not= (:pid lock) (.-pid js/process)))
+                       (try
+                         (.kill js/process (:pid lock) "SIGTERM")
+                         (catch :default e
+                           (log/warn :cli-server-stop-sigterm-failed e))))
+                     (when-not (pid-alive? (:pid lock))
+                       (remove-lock! path))
+                     (if (fs/existsSync path)
+                       {:ok? false
+                        :error {:code :server-stop-timeout
+                                :message "timed out stopping server"}}
+                       (do
+                         (swap! *inproc-servers dissoc repo)
+                         {:ok? true
+                          :data {:repo repo}}))))))))
+
+(defn start-server!
+  [config repo]
+  (p/let [_ (ensure-server-started! config repo)]
+    {:ok? true
+     :data {:repo repo}}))
+
+(defn restart-server!
+  [config repo]
+  (-> (p/let [_ (stop-server! config repo)]
+        (start-server! config repo))
+      (p/catch (fn [_]
+                 (start-server! config repo)))))
+
+(defn server-status
+  [config repo]
+  (let [data-dir (resolve-data-dir config)
+        path (lock-path data-dir repo)
+        lock (read-lock path)]
+    (if-not lock
+      (p/resolved {:ok? true
+                   :data {:repo repo
+                          :status :stopped}})
+      (p/let [ready (ready? lock)]
+        {:ok? true
+         :data {:repo repo
+                :status (if ready :ready :starting)
+                :host (:host lock)
+                :port (:port lock)
+                :pid (:pid lock)
+                :started-at (:startedAt lock)}}))))
+
+(defn list-servers
+  [config]
+  (let [data-dir (resolve-data-dir config)
+        entries (when (fs/existsSync data-dir)
+                  (fs/readdirSync data-dir #js {:withFileTypes true}))]
+    (p/all
+     (for [^js entry entries
+           :when (.isDirectory entry)
+           :let [name (.-name entry)
+                 lock (read-lock (node-path/join data-dir name "db-worker.lock"))]
+           :when lock]
+       (p/let [ready (ready? lock)]
+         {:repo (:repo lock)
+          :host (:host lock)
+          :port (:port lock)
+          :pid (:pid lock)
+          :status (if ready :ready :starting)})))))
+
+(defn list-graphs
+  [config]
+  (let [data-dir (resolve-data-dir config)
+        db-dir-prefix ".logseq-pool-"
+        entries (when (fs/existsSync data-dir)
+                  (fs/readdirSync data-dir #js {:withFileTypes true}))]
+    (->> entries
+         (filter #(.isDirectory ^js %))
+         (map (fn [^js dirent]
+                (.-name dirent)))
+         (filter #(string/starts-with? % db-dir-prefix))
+         (map (fn [dir-name]
+                (-> dir-name
+                    (string/replace-first db-dir-prefix "")
+                    (string/replace "+3A+" ":")
+                    (string/replace "++" "/"))))
+         (vec))))

+ 90 - 5
src/test/frontend/worker/db_worker_node_test.cljs

@@ -3,10 +3,14 @@
             [cljs.test :refer [async deftest is]]
             [clojure.string :as string]
             [frontend.test.node-helper :as node-helper]
+            [frontend.worker-common.util :as worker-util]
             [frontend.worker.db-worker-node :as db-worker-node]
+            [goog.object :as gobj]
             [logseq.db :as ldb]
             [logseq.db.sqlite.util :as sqlite-util]
-            [promesa.core :as p]))
+            [promesa.core :as p]
+            ["fs" :as fs]
+            ["path" :as node-path]))
 
 (defn- http-request
   [opts body]
@@ -56,6 +60,37 @@
       (is (:ok parsed))
       (ldb/read-transit-str (:resultTransit parsed)))))
 
+(defn- invoke-raw
+  [host port method args]
+  (let [payload (js/JSON.stringify
+                 (clj->js {:method method
+                           :directPass false
+                           :argsTransit (ldb/write-transit-str args)}))]
+    (http-request {:hostname host
+                   :port port
+                   :path "/v1/invoke"
+                   :method "POST"
+                   :headers {"Content-Type" "application/json"}}
+                  payload)))
+
+(defn- lock-path
+  [data-dir repo]
+  (let [pool-name (worker-util/get-pool-name repo)
+        repo-dir (node-path/join data-dir (str "." pool-name))]
+    (node-path/join repo-dir "db-worker.lock")))
+
+(deftest db-worker-node-parse-args-ignores-host-and-port
+  (let [parse-args #'db-worker-node/parse-args
+        result (parse-args #js ["node" "db-worker-node.js"
+                                "--host" "0.0.0.0"
+                                "--port" "1234"
+                                "--repo" "logseq_db_parse_args"
+                                "--data-dir" "/tmp/db-worker"])]
+    (is (nil? (:host result)))
+    (is (nil? (:port result)))
+    (is (= "logseq_db_parse_args" (:repo result)))
+    (is (= "/tmp/db-worker" (:data-dir result)))))
+
 (deftest db-worker-node-daemon-smoke-test
   (async done
     (let [daemon (atom nil)
@@ -66,9 +101,8 @@
           block-uuid (random-uuid)]
       (-> (p/let [{:keys [host port stop!]}
                   (db-worker-node/start-daemon!
-                   {:host "127.0.0.1"
-                    :port 0
-                    :data-dir data-dir})
+                   {:data-dir data-dir
+                    :repo repo})
                   health (http-get host port "/healthz")
                   ready (http-get host port "/readyz")
                   _ (do
@@ -88,6 +122,11 @@
                                             (subs repo (count prefix))
                                             repo)]
                         (is (some #(= expected-name (:name %)) dbs))))
+                  lock-file (lock-path data-dir repo)
+                  _ (is (fs/existsSync lock-file))
+                  lock-contents (js/JSON.parse (.toString (fs/readFileSync lock-file) "utf8"))
+                  _ (is (= repo (gobj/get lock-contents "repo")))
+                  _ (is (= host (gobj/get lock-contents "host")))
                   _ (invoke host port "thread-api/transact"
                             [repo
                              [{:block/uuid page-uuid
@@ -119,5 +158,51 @@
           (p/finally (fn []
                        (if-let [stop! (:stop! @daemon)]
                          (-> (stop!)
-                             (p/finally (fn [] (done))))
+                             (p/finally (fn []
+                                          (is (not (fs/existsSync (lock-path data-dir repo))))
+                                          (done))))
+                         (done))))))))
+
+(deftest db-worker-node-repo-mismatch-test
+  (async done
+    (let [daemon (atom nil)
+          data-dir (node-helper/create-tmp-dir "db-worker-repo-mismatch")
+          repo (str "logseq_db_mismatch_" (subs (str (random-uuid)) 0 8))
+          other-repo (str repo "_other")]
+      (-> (p/let [{:keys [host port stop!]}
+                  (db-worker-node/start-daemon! {:data-dir data-dir
+                                                 :repo repo})
+                  _ (reset! daemon {:host host :port port :stop! stop!})
+                  {:keys [status body]} (invoke-raw host port "thread-api/create-or-open-db" [other-repo {}])
+                  parsed (js->clj (js/JSON.parse body) :keywordize-keys true)]
+            (is (= 409 status))
+            (is (= false (:ok parsed)))
+            (is (= "repo-mismatch" (get-in parsed [:error :code]))))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))))
+          (p/finally (fn []
+                       (if-let [stop! (:stop! @daemon)]
+                         (-> (stop!) (p/finally (fn [] (done))))
+                         (done))))))))
+
+(deftest db-worker-node-lock-prevents-multiple-daemons
+  (async done
+    (let [daemon (atom nil)
+          data-dir (node-helper/create-tmp-dir "db-worker-lock")
+          repo (str "logseq_db_lock_" (subs (str (random-uuid)) 0 8))]
+      (-> (p/let [{:keys [stop!]}
+                  (db-worker-node/start-daemon! {:data-dir data-dir
+                                                 :repo repo})
+                  _ (reset! daemon {:stop! stop!})]
+            (-> (db-worker-node/start-daemon! {:data-dir data-dir
+                                               :repo repo})
+                (p/then (fn [_]
+                          (is false "expected lock error")))
+                (p/catch (fn [e]
+                           (is (= :repo-locked (-> (ex-data e) :code)))))))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))))
+          (p/finally (fn []
+                       (if-let [stop! (:stop! @daemon)]
+                         (-> (stop!) (p/finally (fn [] (done))))
                          (done))))))))

+ 110 - 28
src/test/logseq/cli/commands_test.cljs

@@ -1,7 +1,9 @@
 (ns logseq.cli.commands-test
-  (:require [clojure.string :as string]
-            [cljs.test :refer [deftest is testing]]
-            [logseq.cli.commands :as commands]))
+  (:require [cljs.test :refer [async deftest is testing]]
+            [clojure.string :as string]
+            [logseq.cli.commands :as commands]
+            [logseq.cli.server :as cli-server]
+            [promesa.core :as p]))
 
 (deftest test-help-output
   (testing "top-level help lists subcommand groups"
@@ -9,7 +11,8 @@
           summary (:summary result)]
       (is (true? (:help? result)))
       (is (string/includes? summary "graph"))
-      (is (string/includes? summary "block")))))
+      (is (string/includes? summary "block"))
+      (is (string/includes? summary "server")))))
 
 (deftest test-parse-args
   (testing "graph group shows subcommands"
@@ -26,6 +29,13 @@
       (is (string/includes? summary "block add"))
       (is (string/includes? summary "block search"))))
 
+  (testing "server group shows subcommands"
+    (let [result (commands/parse-args ["server"])
+          summary (:summary result)]
+      (is (true? (:help? result)))
+      (is (string/includes? summary "server list"))
+      (is (string/includes? summary "server start"))))
+
   (testing "graph group aligns subcommand columns"
     (let [result (commands/parse-args ["graph"])
           summary (:summary result)
@@ -56,6 +66,21 @@
       (is (seq subcommand-lines))
       (is (apply = desc-starts))))
 
+  (testing "server group aligns subcommand columns"
+    (let [result (commands/parse-args ["server"])
+          summary (:summary result)
+          subcommand-lines (let [lines (string/split-lines summary)
+                                 start (inc (.indexOf lines "Subcommands:"))]
+                             (->> lines
+                                  (drop start)
+                                  (take-while (complement string/blank?))))
+          desc-starts (->> subcommand-lines
+                           (keep (fn [line]
+                                   (when-let [[_ desc] (re-matches #"^\s+.*?\s{2,}(.+)$" line)]
+                                     (.indexOf line desc)))))]
+      (is (seq subcommand-lines))
+      (is (apply = desc-starts))))
+
   (testing "rejects legacy commands"
     (doseq [command ["graph-list" "graph-create" "graph-switch" "graph-remove"
                      "graph-validate" "graph-info" "add" "remove" "search" "tree"
@@ -95,72 +120,94 @@
       (is (true? (:ok? result)))
       (is (= :graph-list (:command result)))))
 
-  (testing "graph create requires graph option"
+  (testing "graph create requires repo option"
     (let [result (commands/parse-args ["graph" "create"])]
       (is (false? (:ok? result)))
       (is (= :missing-graph (get-in result [:error :code])))))
 
-  (testing "graph create parses with graph option"
-    (let [result (commands/parse-args ["graph" "create" "--graph" "demo"])]
+  (testing "graph create parses with repo option"
+    (let [result (commands/parse-args ["graph" "create" "--repo" "demo"])]
       (is (true? (:ok? result)))
       (is (= :graph-create (:command result)))
-      (is (= "demo" (get-in result [:options :graph])))))
+      (is (= "demo" (get-in result [:options :repo])))))
 
-  (testing "graph switch requires graph option"
+  (testing "graph switch requires repo option"
     (let [result (commands/parse-args ["graph" "switch"])]
       (is (false? (:ok? result)))
       (is (= :missing-graph (get-in result [:error :code])))))
 
-  (testing "graph switch parses with graph option"
-    (let [result (commands/parse-args ["graph" "switch" "--graph" "demo"])]
+  (testing "graph switch parses with repo option"
+    (let [result (commands/parse-args ["graph" "switch" "--repo" "demo"])]
       (is (true? (:ok? result)))
       (is (= :graph-switch (:command result)))
-      (is (= "demo" (get-in result [:options :graph])))))
+      (is (= "demo" (get-in result [:options :repo])))))
 
-  (testing "graph remove requires graph option"
+  (testing "graph remove requires repo option"
     (let [result (commands/parse-args ["graph" "remove"])]
       (is (false? (:ok? result)))
       (is (= :missing-graph (get-in result [:error :code])))))
 
-  (testing "graph remove parses with graph option"
-    (let [result (commands/parse-args ["graph" "remove" "--graph" "demo"])]
+  (testing "graph remove parses with repo option"
+    (let [result (commands/parse-args ["graph" "remove" "--repo" "demo"])]
       (is (true? (:ok? result)))
       (is (= :graph-remove (:command result)))
-      (is (= "demo" (get-in result [:options :graph])))))
+      (is (= "demo" (get-in result [:options :repo])))))
 
-  (testing "graph validate requires graph option"
+  (testing "graph validate requires repo option"
     (let [result (commands/parse-args ["graph" "validate"])]
       (is (false? (:ok? result)))
       (is (= :missing-graph (get-in result [:error :code])))))
 
-  (testing "graph validate parses with graph option"
-    (let [result (commands/parse-args ["graph" "validate" "--graph" "demo"])]
+  (testing "graph validate parses with repo option"
+    (let [result (commands/parse-args ["graph" "validate" "--repo" "demo"])]
       (is (true? (:ok? result)))
       (is (= :graph-validate (:command result)))
-      (is (= "demo" (get-in result [:options :graph])))))
+      (is (= "demo" (get-in result [:options :repo])))))
 
-  (testing "graph info parses without graph option"
+  (testing "graph info parses without repo option"
     (let [result (commands/parse-args ["graph" "info"])]
       (is (true? (:ok? result)))
       (is (= :graph-info (:command result)))))
 
-  (testing "graph info parses with graph option"
-    (let [result (commands/parse-args ["graph" "info" "--graph" "demo"])]
+  (testing "graph info parses with repo option"
+    (let [result (commands/parse-args ["graph" "info" "--repo" "demo"])]
       (is (true? (:ok? result)))
       (is (= :graph-info (:command result)))
-      (is (= "demo" (get-in result [:options :graph])))))
+      (is (= "demo" (get-in result [:options :repo])))))
 
   (testing "graph subcommands reject unknown flags"
     (doseq [subcommand ["list" "create" "switch" "remove" "validate" "info"]]
       (let [result (commands/parse-args ["graph" subcommand "--wat"])]
         (is (false? (:ok? result)))
-        (is (= :invalid-options (get-in result [:error :code])))))))
+        (is (= :invalid-options (get-in result [:error :code]))))))
+
 
   (testing "graph subcommands accept output option"
     (let [result (commands/parse-args ["graph" "list" "--output" "edn"])]
       (is (true? (:ok? result)))
       (is (= "edn" (get-in result [:options :output])))))
 
+  (testing "server list parses"
+    (let [result (commands/parse-args ["server" "list"])]
+      (is (true? (:ok? result)))
+      (is (= :server-list (:command result)))))
+
+  (testing "server start requires repo"
+    (let [result (commands/parse-args ["server" "start"])]
+      (is (false? (:ok? result)))
+      (is (= :missing-repo (get-in result [:error :code])))))
+
+  (testing "server start parses with repo"
+    (let [result (commands/parse-args ["server" "start" "--repo" "demo"])]
+      (is (true? (:ok? result)))
+      (is (= :server-start (:command result)))
+      (is (= "demo" (get-in result [:options :repo])))))
+
+  (testing "server stop parses with repo"
+    (let [result (commands/parse-args ["server" "stop" "--repo" "demo"])]
+      (is (true? (:ok? result)))
+      (is (= :server-stop (:command result))))))
+
 (deftest test-block-subcommand-parse
   (testing "block add requires content source"
     (let [result (commands/parse-args ["block" "add"])]
@@ -222,16 +269,34 @@
     (let [parsed {:ok? true :command :graph-list :options {}}
           result (commands/build-action parsed {})]
       (is (true? (:ok? result)))
-      (is (= "thread-api/list-db" (get-in result [:action :method])))))
+      (is (= :graph-list (get-in result [:action :type])))))
+
+  (testing "server list builds action"
+    (let [parsed {:ok? true :command :server-list :options {}}
+          result (commands/build-action parsed {})]
+      (is (true? (:ok? result)))
+      (is (= :server-list (get-in result [:action :type])))))
+
+  (testing "server start requires repo"
+    (let [parsed {:ok? true :command :server-start :options {}}
+          result (commands/build-action parsed {})]
+      (is (false? (:ok? result)))
+      (is (= :missing-repo (get-in result [:error :code])))))
 
-  (testing "graph-create requires graph name"
+  (testing "server stop builds action"
+    (let [parsed {:ok? true :command :server-stop :options {:repo "demo"}}
+          result (commands/build-action parsed {})]
+      (is (true? (:ok? result)))
+      (is (= :server-stop (get-in result [:action :type])))))
+
+  (testing "graph-create requires repo name"
     (let [parsed {:ok? true :command :graph-create :options {}}
           result (commands/build-action parsed {})]
       (is (false? (:ok? result)))
       (is (= :missing-graph (get-in result [:error :code])))))
 
   (testing "graph-switch uses graph name"
-    (let [parsed {:ok? true :command :graph-switch :options {:graph "demo"}}
+    (let [parsed {:ok? true :command :graph-switch :options {:repo "demo"}}
           result (commands/build-action parsed {})]
       (is (true? (:ok? result)))
       (is (= :graph-switch (get-in result [:action :type])))))
@@ -271,3 +336,20 @@
           result (commands/build-action parsed {:repo "demo"})]
       (is (false? (:ok? result)))
       (is (= :missing-target (get-in result [:error :code]))))))
+
+(deftest test-execute-requires-existing-graph
+  (async done
+         (with-redefs [cli-server/list-graphs (fn [_] [])
+                       cli-server/ensure-server! (fn [_ _]
+                                                   (throw (ex-info "should not start server" {})))]
+           (-> (p/let [result (commands/execute {:type :search
+                                                 :repo "logseq_db_missing"
+                                                 :text "hello"}
+                                                {})]
+                 (is (= :error (:status result)))
+                 (is (= :graph-not-exists (get-in result [:error :code])))
+                 (is (= "graph not exists" (get-in result [:error :message])))
+                 (done))
+               (p/catch (fn [e]
+                          (is false (str "unexpected error: " e))
+                          (done)))))))

+ 11 - 15
src/test/logseq/cli/config_test.cljs

@@ -23,47 +23,43 @@
   (let [dir (node-helper/create-tmp-dir)
         cfg-path (node-path/join dir "cli.edn")
         _ (fs/writeFileSync cfg-path
-                            (str "{:base-url \"http://file:7777\" "
-                                 ":auth-token \"file-token\" "
+                            (str "{:auth-token \"file-token\" "
                                  ":repo \"file-repo\" "
+                                 ":data-dir \"file-data\" "
                                  ":timeout-ms 111 "
                                  ":retries 1 "
                                  ":output-format :edn}"))
-        env {"LOGSEQ_DB_WORKER_URL" "http://env:9999"
-             "LOGSEQ_DB_WORKER_AUTH_TOKEN" "env-token"
+        env {"LOGSEQ_DB_WORKER_AUTH_TOKEN" "env-token"
              "LOGSEQ_CLI_REPO" "env-repo"
+             "LOGSEQ_CLI_DATA_DIR" "env-data"
              "LOGSEQ_CLI_TIMEOUT_MS" "222"
              "LOGSEQ_CLI_RETRIES" "2"
              "LOGSEQ_CLI_OUTPUT" "json"}
         opts {:config-path cfg-path
-              :base-url "http://cli:1234"
               :auth-token "cli-token"
               :repo "cli-repo"
+              :data-dir "cli-data"
               :timeout-ms 333
               :retries 3
               :output-format :human}
         result (with-env env #(config/resolve-config opts))]
     (is (= cfg-path (:config-path result)))
-    (is (= "http://cli:1234" (:base-url result)))
     (is (= "cli-token" (:auth-token result)))
     (is (= "cli-repo" (:repo result)))
+    (is (= "cli-data" (:data-dir result)))
     (is (= 333 (:timeout-ms result)))
     (is (= 3 (:retries result)))
     (is (= :human (:output-format result)))))
 
-(deftest test-host-port-derived-base-url
-  (let [result (config/resolve-config {:host "127.0.0.2" :port 9200})]
-    (is (= "http://127.0.0.2:9200" (:base-url result)))))
-
 (deftest test-env-overrides-file
   (let [dir (node-helper/create-tmp-dir)
         cfg-path (node-path/join dir "cli.edn")
-        _ (fs/writeFileSync cfg-path "{:base-url \"http://file:7777\" :repo \"file-repo\"}")
-        env {"LOGSEQ_DB_WORKER_URL" "http://env:9999"
-             "LOGSEQ_CLI_REPO" "env-repo"}
+        _ (fs/writeFileSync cfg-path "{:repo \"file-repo\" :data-dir \"file-data\"}")
+        env {"LOGSEQ_CLI_REPO" "env-repo"
+             "LOGSEQ_CLI_DATA_DIR" "env-data"}
         result (with-env env #(config/resolve-config {:config-path cfg-path}))]
-    (is (= "http://env:9999" (:base-url result)))
-    (is (= "env-repo" (:repo result)))))
+    (is (= "env-repo" (:repo result)))
+    (is (= "env-data" (:data-dir result)))))
 
 (deftest test-output-format-env-overrides-file
   (let [dir (node-helper/create-tmp-dir)

+ 40 - 48
src/test/logseq/cli/integration_test.cljs

@@ -3,19 +3,24 @@
             [cljs.test :refer [deftest is async]]
             [clojure.string :as string]
             [frontend.test.node-helper :as node-helper]
-            [frontend.worker.db-worker-node :as db-worker-node]
             [logseq.cli.main :as cli-main]
             [promesa.core :as p]
             ["fs" :as fs]
             ["path" :as node-path]))
 
 (defn- run-cli
-  [args url cfg-path]
+  [args data-dir cfg-path]
   (let [args-with-output (if (some #{"--output"} args)
                            args
-                           (concat args ["--output" "json"]))]
-    (cli-main/run! (vec (concat args-with-output ["--base-url" url "--config" cfg-path]))
-                   {:exit? false})))
+                           (concat args ["--output" "json"]))
+        global-opts ["--data-dir" data-dir "--config" cfg-path]
+        final-args (vec (concat global-opts args-with-output))]
+    (-> (cli-main/run! final-args {:exit? false})
+        (p/then (fn [result]
+                  (let [res (if (map? result)
+                              result
+                              (js->clj result :keywordize-keys true))]
+                    res))))))
 
 (defn- parse-json-output
   [result]
@@ -28,18 +33,13 @@
 (deftest test-cli-graph-list
   (async done
     (let [data-dir (node-helper/create-tmp-dir "db-worker")]
-      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
-                                                        :port 0
-                                                        :data-dir data-dir})
-                  url (str "http://127.0.0.1:" (:port daemon))
-                  cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
-                  result (run-cli ["graph" "list"] url cfg-path)
+      (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
+                  result (run-cli ["graph" "list"] data-dir cfg-path)
                   payload (parse-json-output result)]
             (is (= 0 (:exit-code result)))
             (is (= "ok" (:status payload)))
             (is (contains? payload :data))
-            (p/let [_ ((:stop! daemon))]
-              (done)))
+            (done))
           (p/catch (fn [e]
                      (is false (str "unexpected error: " e))
                      (done)))))))
@@ -47,23 +47,22 @@
 (deftest test-cli-graph-create-and-info
   (async done
     (let [data-dir (node-helper/create-tmp-dir "db-worker")]
-      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
-                                                        :port 0
-                                                        :data-dir data-dir})
-                  url (str "http://127.0.0.1:" (:port daemon))
-                  cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
-                  _ (fs/writeFileSync cfg-path "{}")
-                  create-result (run-cli ["graph" "create" "--graph" "demo-graph"] url cfg-path)
+      (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
+                  _ (fs/writeFileSync cfg-path "{:output-format :json}")
+                  create-result (run-cli ["graph" "create" "--repo" "demo-graph"] data-dir cfg-path)
                   create-payload (parse-json-output create-result)
-                  info-result (run-cli ["graph" "info"] url cfg-path)
-                  info-payload (parse-json-output info-result)]
+                  info-result (run-cli ["graph" "info"] data-dir cfg-path)
+                  info-payload (parse-json-output info-result)
+                  stop-result (run-cli ["server" "stop" "--repo" "demo-graph"] data-dir cfg-path)
+                  stop-payload (parse-json-output stop-result)]
             (is (= 0 (:exit-code create-result)))
             (is (= "ok" (:status create-payload)))
             (is (= 0 (:exit-code info-result)))
             (is (= "ok" (:status info-payload)))
             (is (= "demo-graph" (get-in info-payload [:data :graph])))
-            (p/let [_ ((:stop! daemon))]
-              (done)))
+            (is (= 0 (:exit-code stop-result)))
+            (is (= "ok" (:status stop-payload)))
+            (done))
           (p/catch (fn [e]
                      (is false (str "unexpected error: " e))
                      (done)))))))
@@ -71,29 +70,27 @@
 (deftest test-cli-add-search-tree-remove
   (async done
     (let [data-dir (node-helper/create-tmp-dir "db-worker")]
-      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
-                                                        :port 0
-                                                        :data-dir data-dir})
-                  url (str "http://127.0.0.1:" (:port daemon))
-                  cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
-                  _ (fs/writeFileSync cfg-path "{}")
-                  _ (run-cli ["graph" "create" "--graph" "content-graph"] url cfg-path)
-                  add-result (run-cli ["block" "add" "--page" "TestPage" "--content" "hello world"] url cfg-path)
+      (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
+                  _ (fs/writeFileSync cfg-path "{:output-format :json}")
+                  _ (run-cli ["graph" "create" "--repo" "content-graph"] data-dir cfg-path)
+                  add-result (run-cli ["block" "add" "--page" "TestPage" "--content" "hello world"] data-dir cfg-path)
                   _ (parse-json-output add-result)
-                  search-result (run-cli ["block" "search" "--text" "hello world"] url cfg-path)
+                  search-result (run-cli ["block" "search" "--text" "hello world"] data-dir cfg-path)
                   search-payload (parse-json-output search-result)
-                  tree-result (run-cli ["block" "tree" "--page" "TestPage" "--format" "json"] url cfg-path)
+                  tree-result (run-cli ["block" "tree" "--page" "TestPage" "--format" "json"] data-dir cfg-path)
                   tree-payload (parse-json-output tree-result)
                   block-uuid (get-in tree-payload [:data :root :children 0 :uuid])
-                  remove-result (run-cli ["block" "remove" "--block" (str block-uuid)] url cfg-path)
-                  remove-payload (parse-json-output remove-result)]
+                  remove-result (run-cli ["block" "remove" "--block" (str block-uuid)] data-dir cfg-path)
+                  remove-payload (parse-json-output remove-result)
+                  stop-result (run-cli ["server" "stop" "--repo" "content-graph"] data-dir cfg-path)
+                  stop-payload (parse-json-output stop-result)]
             (is (= 0 (:exit-code add-result)))
             (is (= "ok" (:status search-payload)))
             (is (seq (get-in search-payload [:data :results])))
             (is (= "ok" (:status tree-payload)))
             (is (= "ok" (:status remove-payload)))
-            (p/let [_ ((:stop! daemon))]
-              (done)))
+            (is (= "ok" (:status stop-payload)))
+            (done))
           (p/catch (fn [e]
                      (is false (str "unexpected error: " e))
                      (done)))))))
@@ -101,23 +98,18 @@
 (deftest test-cli-output-formats-graph-list
   (async done
     (let [data-dir (node-helper/create-tmp-dir "db-worker")]
-      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
-                                                        :port 0
-                                                        :data-dir data-dir})
-                  url (str "http://127.0.0.1:" (:port daemon))
-                  cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
-                  json-result (run-cli ["graph" "list" "--output" "json"] url cfg-path)
+      (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
+                  json-result (run-cli ["graph" "list" "--output" "json"] data-dir cfg-path)
                   json-payload (parse-json-output json-result)
-                  edn-result (run-cli ["graph" "list" "--output" "edn"] url cfg-path)
+                  edn-result (run-cli ["graph" "list" "--output" "edn"] data-dir cfg-path)
                   edn-payload (parse-edn-output edn-result)
-                  human-result (run-cli ["graph" "list" "--output" "human"] url cfg-path)]
+                  human-result (run-cli ["graph" "list" "--output" "human"] data-dir cfg-path)]
             (is (= 0 (:exit-code json-result)))
             (is (= "ok" (:status json-payload)))
             (is (= 0 (:exit-code edn-result)))
             (is (= :ok (:status edn-payload)))
             (is (not (string/starts-with? (:output human-result) "{:status")))
-            (p/let [_ ((:stop! daemon))]
-              (done)))
+            (done))
           (p/catch (fn [e]
                      (is false (str "unexpected error: " e))
                      (done)))))))

+ 53 - 0
src/test/logseq/cli/server_test.cljs

@@ -0,0 +1,53 @@
+(ns logseq.cli.server-test
+  (:require [cljs.test :refer [async deftest is]]
+            [frontend.test.node-helper :as node-helper]
+            [logseq.cli.server :as cli-server]
+            [promesa.core :as p]
+            [clojure.string :as string]
+            ["fs" :as fs]
+            ["path" :as node-path]
+            ["child_process" :as child-process]))
+
+(deftest spawn-server-omits-host-and-port-flags
+  (let [spawn-server! #'cli-server/spawn-server!
+        captured (atom nil)
+        original-spawn (.-spawn child-process)]
+    (set! (.-spawn child-process)
+          (fn [cmd args opts]
+            (reset! captured {:cmd cmd
+                              :args (vec (js->clj args))
+                              :opts (js->clj opts :keywordize-keys true)})
+            (js-obj "unref" (fn [] nil))))
+    (try
+      (spawn-server! {:repo "logseq_db_spawn_test"
+                      :data-dir "/tmp/logseq-db-worker"})
+      (is (= "node" (:cmd @captured)))
+      (is (some #{"--repo"} (:args @captured)))
+      (is (some #{"--data-dir"} (:args @captured)))
+      (is (not-any? #{"--host" "--port"} (:args @captured)))
+      (finally
+        (set! (.-spawn child-process) original-spawn)))))
+
+(deftest ensure-server-repairs-stale-lock
+  (async done
+    (let [data-dir (node-helper/create-tmp-dir "cli-server")
+          repo (str "logseq_db_stale_" (subs (str (random-uuid)) 0 8))
+          path (cli-server/lock-path data-dir repo)
+          lock {:repo repo
+                :pid (.-pid js/process)
+                :host "127.0.0.1"
+                :port 0
+                :startedAt (.toISOString (js/Date.))}]
+      (fs/mkdirSync (node-path/dirname path) #js {:recursive true})
+      (fs/writeFileSync path (js/JSON.stringify (clj->js lock)))
+      (-> (p/let [cfg (cli-server/ensure-server! {:data-dir data-dir} repo)
+                  _ (is (string/starts-with? (:base-url cfg) "http://127.0.0.1:"))
+                  lock-data (js->clj (js/JSON.parse (.toString (fs/readFileSync path) "utf8"))
+                                     :keywordize-keys true)
+                  _ (is (pos-int? (:port lock-data)))
+                  stop-result (cli-server/stop-server! {:data-dir data-dir} repo)]
+            (is (:ok? stop-result))
+            (done))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))

+ 1 - 1
src/test/logseq/cli/transport_test.cljs

@@ -24,7 +24,7 @@
   (async done
     (let [calls (atom 0)]
       (-> (p/let [{:keys [url stop!]} (start-server
-                                       (fn [_req res]
+                                       (fn [_req ^js res]
                                          (let [attempt (swap! calls inc)]
                                            (if (= attempt 1)
                                              (do