Fase 4: migrate the daemon event channel from SSE to WebSocket with resume-by-revision

Adds a WS upgrade handler alongside the existing SSE stream on /v1/events
(kept alive for instant rollback), authenticated via Sec-WebSocket-Protocol
instead of a query-string token. The daemon now keeps a ~500-entry circular
buffer of broadcast sync-db-changes deltas indexed by renderer revision; a
client reconnecting sends {type: subscribe, graphId, sinceRev} and either
gets a gap-free replay + ack, or a {type: resync} instructing it to discard
all cached renderer state and reload (frontend.db.subs/reset-graph!, wired
through a new :db/resync event). The web/Electron client transport
(frontend.persist-db.remote) now opens the WS channel by default; the SSE
path (connect-events!) is untouched and still fully wired.

Also fixes new/pre-existing test breakage surfaced by switching the default
transport: persist_db_test.cljs's shared Electron-failover fixture now mocks
open-ws-fn (not just open-sse-fn), and a `new` applied to an inline (or ...)
expression in make-server's WebSocketServer construction is replaced with
the same let-bound-symbol pattern deps/db-sync's node/server.cljs already
uses -- inlining it silently dropped `new`, breaking every daemon test.

Confirmed 7 unrelated persist_db_test.cljs failures (electron-ensure-remote-*
IPC bookkeeping) already fail identically on the pre-Fase-4 baseline -- left
untouched, out of scope here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 10:12:53 +02:00
parent be7abd52a3
commit cf61c46c35
10 changed files with 636 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
(ns frontend.persist-db.remote
"Remote `PersistentDB` implementation for Electron renderer via db-worker-node HTTP and SSE."
(:require [clojure.string :as string]
[frontend.db.subs :as db-subs]
[frontend.persist-db.protocol :as protocol]
[logseq.db :as ldb]
[promesa.core :as p]
@@ -32,6 +33,12 @@
(cond-> (str (normalize-base-url base-url) "/v1/events")
(seq auth-token) (str "?token=" (js/encodeURIComponent auth-token))))
(defn- events-ws-url
[base-url]
(-> (normalize-base-url base-url)
(string/replace #"^http" "ws")
(str "/v1/events")))
(defn- base-headers
[auth-token]
(cond-> {"Content-Type" "application/json"
@@ -115,8 +122,12 @@
(subs line 6)))
(string/split-lines event-text)))
(defn- default-open-ws-fn
[{:keys [url auth-token]}]
(js/WebSocket. url (str "bearer." auth-token)))
(defn create-client
[{:keys [fetch-fn open-sse-fn schedule-fn reconnect-delay-ms] :as opts}]
[{:keys [fetch-fn open-sse-fn open-ws-fn schedule-fn reconnect-delay-ms] :as opts}]
(let [default-fetch-fn
(fn [{:keys [method url headers body]}]
(p/let [^js res (js/fetch url (clj->js (cond-> {:method method
@@ -143,6 +154,7 @@
(assoc opts
:fetch-fn (or fetch-fn default-fetch-fn)
:open-sse-fn (or open-sse-fn default-open-sse-fn)
:open-ws-fn (or open-ws-fn default-open-ws-fn)
:schedule-fn (or schedule-fn (fn [f delay-ms]
(js/setTimeout f delay-ms)))
:reconnect-delay-ms (or reconnect-delay-ms 1000))))
@@ -244,6 +256,73 @@
(close-subscription!)
nil)})))
(defn connect-events-ws!
"WebSocket transport for the daemon event channel -- the default since
Fase 4 (connect-events! above stays fully wired as an instant rollback,
see docs/agent-guide). Resumes by revision on every (re)connect: sends
{type: subscribe, graphId: repo, sinceRev: N} where N is the renderer's
last applied revision. The server replays buffered deltas gap-free, or --
if the gap is wider than its ~500-entry buffer -- replies {type: resync},
handled here as a normal event-handler dispatch so the existing
frontend.handler.worker / :db/resync wiring can discard cached renderer
state and reload."
[{:keys [base-url auth-token repo event-handler open-ws-fn schedule-fn
reconnect-delay-ms on-event-error current-rev-fn]}
wrapped-worker]
(let [connected? (atom true)
socket (atom nil)
current-rev-fn (or current-rev-fn db-subs/current-rev)
close-socket! (fn []
(when-let [^js s @socket]
(set! (.-onopen s) nil)
(set! (.-onmessage s) nil)
(set! (.-onerror s) nil)
(set! (.-onclose s) nil)
(try (.close s) (catch :default _ nil)))
(reset! socket nil))
dispatch-control! (fn [{:keys [type rev message]}]
(case type
"resync" (when event-handler
(event-handler :resync wrapped-worker {:rev rev}))
"error" (log/error :db-worker-node-ws-server-error {:message message})
nil))
dispatch-business! (fn [event]
(let [[event-type payload] (decode-event event)]
(when (and event-handler event-type)
(event-handler event-type wrapped-worker payload))))]
(letfn [(open! []
(when @connected?
(let [^js s (open-ws-fn {:url (events-ws-url base-url) :auth-token auth-token})]
(reset! socket s)
(set! (.-onopen s)
(fn [_]
(when @connected?
(.send s (js/JSON.stringify
(clj->js {:type "subscribe"
:graphId repo
:sinceRev (current-rev-fn)}))))))
(set! (.-onmessage s)
(fn [event]
(when @connected?
(let [parsed (parse-response-body (.-data event))]
(if (contains? parsed :payload)
(dispatch-business! parsed)
(dispatch-control! parsed))))))
(set! (.-onerror s)
(fn [error]
(when (and @connected? on-event-error)
(on-event-error error))))
(set! (.-onclose s)
(fn [_]
(reset! socket nil)
(when @connected?
(schedule-fn open! reconnect-delay-ms)))))))]
(open!)
{:disconnect! (fn []
(reset! connected? false)
(close-socket!)
nil)})))
(defn- method->str
[qkw]
(str (namespace qkw) "/" (name qkw)))
@@ -280,10 +359,10 @@
(let [client (create-client (assoc opts :base-url base-url :auth-token auth-token))
wrapped-worker (fn [qkw & args]
(invoke! client (method->str qkw) args))
{:keys [disconnect!]} (connect-events! (assoc client
:event-handler event-handler
:auth-token auth-token)
wrapped-worker)]
{:keys [disconnect!]} (connect-events-ws! (assoc client
:event-handler event-handler
:auth-token auth-token)
wrapped-worker)]
(->InRemote client wrapped-worker disconnect!)))
(defn stop!