refactor: extract shared .cljc library with store protocol

Move core, sync, and transit from platform-specific .clj/.cljs to
shared .cljc files with reader conditionals. This enables testing
the full sync logic on the JVM and using SyncedAtom from Clojure
clients.

Key changes:
- PStore protocol (store.cljc) decouples core from storage backend
- IDB store (store/idb.cljs) and memory store (store/memory.cljc)
- SyncedAtom implements CLJ IDeref/IAtom/IRef + CLJS equivalents
- Sync client uses java.net.http on CLJ, fetch on CLJS
- SSE remains CLJS-only; JVM clients use polling
- API change: store passed explicitly instead of pb/open
- 7 new JVM tests: local ops, persistence, watches, two-client sync
- 28 tests total, 87 assertions, all passing
This commit is contained in:
Florian Schroedl
2026-04-16 19:42:06 +02:00
parent 5ab102b550
commit 86b54e1291
15 changed files with 711 additions and 552 deletions

View File

@@ -0,0 +1,185 @@
(ns pocketbook.core-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[clojure.core.async :as async :refer [<!! go <! timeout]]
[pocketbook.core :as pb]
[pocketbook.store :as store]
[pocketbook.store.memory :as memory]
[pocketbook.server :as server])
(:import [java.io File]))
;; ---------------------------------------------------------------------------
;; Fixtures — start a real server for sync tests
;; ---------------------------------------------------------------------------
(def ^:dynamic *port* nil)
(def ^:dynamic *server* nil)
(defn- free-port []
(with-open [s (java.net.ServerSocket. 0)]
(.getLocalPort s)))
(use-fixtures :each
(fn [f]
(let [port (free-port)
db-path (str (File/createTempFile "pocketbook-core-test" ".db"))
srv (server/start! {:port port :db-path db-path})]
(Thread/sleep 200)
(try
(binding [*server* srv *port* port]
(f))
(finally
(server/stop! srv)
(.delete (File. db-path)))))))
(defn- server-url []
(str "http://localhost:" *port* "/sync"))
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------
(defn- <!!timeout
"Take from channel with timeout. Returns nil on timeout."
[ch ms]
(let [[v _] (<!! (go (async/alts! [ch (timeout ms)])))]
v))
(defn- wait-synced
"Wait until the synced atom has no pending changes."
[sa ms]
(let [deadline (+ (System/currentTimeMillis) ms)]
(loop []
(when (and (pos? (pb/pending-count sa))
(< (System/currentTimeMillis) deadline))
(Thread/sleep 50)
(recur)))))
;; ---------------------------------------------------------------------------
;; Tests
;; ---------------------------------------------------------------------------
(deftest synced-atom-local-only
(testing "SyncedAtom works without a server (local store only)"
(let [store (memory/create)
sa (pb/synced-atom store "todo")]
(<!!timeout (pb/ready? sa) 1000)
(is (= {} @sa))
(swap! sa assoc "todo:1" {:text "Buy milk"})
(is (= {:text "Buy milk"} (get @sa "todo:1")))
(swap! sa assoc "todo:2" {:text "Walk dog"})
(is (= 2 (count @sa)))
(swap! sa dissoc "todo:1")
(is (= 1 (count @sa)))
(is (nil? (get @sa "todo:1")))
(pb/destroy! sa))))
(deftest synced-atom-persists-to-store
(testing "Changes are persisted to the store"
(let [store (memory/create)
sa (pb/synced-atom store "todo")]
(<!!timeout (pb/ready? sa) 1000)
(swap! sa assoc "todo:1" {:text "Buy milk"})
(Thread/sleep 50) ;; let async store write complete
;; Read from store directly
(let [docs (<!!timeout (store/docs-by-prefix store "todo:") 1000)]
(is (= 1 (count docs)))
(is (= "todo:1" (:id (first docs))))
(is (= {:text "Buy milk"} (:value (first docs)))))
(pb/destroy! sa))))
(deftest synced-atom-loads-from-store
(testing "SyncedAtom loads existing data from store on creation"
(let [store (memory/create)]
;; Pre-populate the store
(<!!timeout (store/put-doc! store
{:id "todo:1" :value {:text "Existing"}
:version 1 :updated 1000 :deleted false :synced true})
1000)
(let [sa (pb/synced-atom store "todo")]
(<!!timeout (pb/ready? sa) 1000)
(is (= {:text "Existing"} (get @sa "todo:1")))
(pb/destroy! sa)))))
(deftest synced-atom-watches
(testing "add-watch fires on changes"
(let [store (memory/create)
sa (pb/synced-atom store "todo")
changes (atom [])]
(<!!timeout (pb/ready? sa) 1000)
(add-watch sa :test (fn [_ _ old new]
(swap! changes conj {:old old :new new})))
(swap! sa assoc "todo:1" {:text "Hello"})
(Thread/sleep 50)
(is (= 1 (count @changes)))
(is (= {} (:old (first @changes))))
(is (= {"todo:1" {:text "Hello"}} (:new (first @changes))))
(remove-watch sa :test)
(pb/destroy! sa))))
(deftest synced-atom-push-to-server
(testing "Local changes are pushed to the server"
(let [store (memory/create)
sa (pb/synced-atom store "todo"
{:server (server-url)})]
(<!!timeout (pb/ready? sa) 2000)
(swap! sa assoc "todo:push1" {:text "Pushed!"})
(Thread/sleep 500) ;; let push complete
(is (zero? (pb/pending-count sa)))
(pb/destroy! sa))))
(deftest synced-atom-pull-from-server
(testing "Two clients sync via server"
(let [store-a (memory/create)
store-b (memory/create)
sa-a (pb/synced-atom store-a "todo"
{:server (server-url) :interval 500})
sa-b (pb/synced-atom store-b "todo"
{:server (server-url) :interval 500})]
(<!!timeout (pb/ready? sa-a) 2000)
(<!!timeout (pb/ready? sa-b) 2000)
;; Client A writes
(swap! sa-a assoc "todo:sync1" {:text "From A"})
(Thread/sleep 500) ;; let A push
;; Trigger a sync on B
(<!!timeout (pb/sync-now! sa-b) 2000)
;; B should have A's data
(is (= {:text "From A"} (get @sa-b "todo:sync1")))
(pb/destroy! sa-a)
(pb/destroy! sa-b))))
(deftest synced-atom-deref-swap-reset
(testing "Standard atom operations work"
(let [store (memory/create)
sa (pb/synced-atom store "note")]
(<!!timeout (pb/ready? sa) 1000)
;; reset!
(reset! sa {"note:1" {:body "Hello"}})
(is (= {"note:1" {:body "Hello"}} @sa))
;; swap! with multiple args
(swap! sa assoc "note:2" {:body "World"})
(is (= 2 (count @sa)))
(swap! sa update "note:1" assoc :edited true)
(is (true? (:edited (get @sa "note:1"))))
(pb/destroy! sa))))

View File

@@ -41,19 +41,19 @@
(.header "Accept" "application/transit+json")
(.GET)
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))
(defn- post-transit [path body]
(let [bytes (t/encode body)
req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path)))
(.header "Content-Type" "application/transit+json")
(.header "Accept" "application/transit+json")
(.POST (HttpRequest$BodyPublishers/ofByteArray bytes))
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
(let [encoded (t/encode body)
req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path)))
(.header "Content-Type" "application/transit+json")
(.header "Accept" "application/transit+json")
(.POST (HttpRequest$BodyPublishers/ofString encoded))
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))

View File

@@ -30,8 +30,8 @@
:version 5}]]
(is (= data (t/decode (t/encode data))))))
(deftest encode-str-roundtrip
(deftest encode-returns-string
(let [v {:hello "world" :nums [1 2 3]}
s (t/encode-str v)]
s (t/encode v)]
(is (string? s))
(is (= v (t/decode s)))))