feat: implement Pocketbook — a Clojure-native synced atom

Offline-first key-value store with atom interface (swap!, deref,
add-watch) that syncs to a SQLite-backed server over Transit.

Server (CLJ):
- SQLite storage with Nippy serialization preserving all Clojure types
- GET /sync?group=G&since=T pull endpoint with prefix-based groups
- POST /sync push endpoint with per-document version checking
- Conflict detection (stale write rejection)
- Token-based auth with per-user group access
- CORS support, soft deletes, purge compaction

Client (CLJS):
- IndexedDB wrapper with Transit serialization
- SyncedAtom implementing IAtom (IDeref, ISwap, IReset, IWatchable)
- Write-through to IndexedDB on every swap!
- Background sync loop (pull + push) with configurable interval
- Online/offline detection with reconnect sync
- Conflict resolution (accept server value)
- ready? channel for initial load
- Custom cache atom support (Reagent ratom compatible)

25 tests, 77 assertions across db, transit, server, and auth.
This commit is contained in:
Florian Schroedl
2026-04-04 16:33:14 +02:00
commit 55cddf751b
16 changed files with 1736 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
(ns pocketbook.auth-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[pocketbook.server :as server]
[pocketbook.transit :as t])
(:import [java.io File]
[java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers]))
(def ^:dynamic *port* nil)
(def ^:dynamic *server* nil)
(defn- free-port []
(with-open [s (java.net.ServerSocket. 0)]
(.getLocalPort s)))
(def test-users
{"alice" {:token "alice-secret" :groups #{"todo" "settings"}}
"bob" {:token "bob-secret" :groups #{"todo"}}})
(use-fixtures :each
(fn [f]
(let [port (free-port)
db-path (str (File/createTempFile "pocketbook-auth-test" ".db"))
srv (server/start! {:port port :db-path db-path :users test-users})]
(Thread/sleep 200)
(try
(binding [*server* srv *port* port]
(f))
(finally
(server/stop! srv)
(.delete (File. db-path)))))))
(def ^:private client (HttpClient/newHttpClient))
(defn- url [path & [query]]
(str "http://localhost:" *port* path (when query (str "?" query))))
(defn- get-transit [path query & [token]]
(let [req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path query)))
(.header "Accept" "application/transit+json")
(cond-> token (.header "Authorization" (str "Bearer " token)))
(.GET)
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))
(defn- post-transit [path body & [token]]
(let [bytes (t/encode body)
req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path)))
(.header "Content-Type" "application/transit+json")
(.header "Accept" "application/transit+json")
(cond-> token (.header "Authorization" (str "Bearer " token)))
(.POST (HttpRequest$BodyPublishers/ofByteArray bytes))
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))
(deftest unauthorized-without-token
(let [resp (get-transit "/sync" "group=todo&since=0")]
(is (= 401 (:status resp)))))
(deftest unauthorized-with-bad-token
(let [resp (get-transit "/sync" "group=todo&since=0" "wrong-token")]
(is (= 401 (:status resp)))))
(deftest alice-can-access-todo
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "Alice todo"} :base-version 0}]
"alice-secret")]
(is (= 200 (:status resp)))
(is (= :ok (:status (first (:body resp)))))))
(deftest bob-cannot-access-settings
(let [resp (post-transit "/sync"
[{:id "settings:theme" :value {:dark true} :base-version 0}]
"bob-secret")]
(is (= 403 (:status resp)))))
(deftest alice-can-access-settings
(let [resp (post-transit "/sync"
[{:id "settings:theme" :value {:dark true} :base-version 0}]
"alice-secret")]
(is (= 200 (:status resp)))
(is (= :ok (:status (first (:body resp)))))))

141
test/pocketbook/db_test.clj Normal file
View File

@@ -0,0 +1,141 @@
(ns pocketbook.db-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[pocketbook.db :as db])
(:import [java.io File]))
(def ^:dynamic *ds* nil)
(defn- temp-db-path []
(str (File/createTempFile "pocketbook-test" ".db")))
(use-fixtures :each
(fn [f]
(let [path (temp-db-path)
ds (db/open path)]
(try
(binding [*ds* ds]
(f))
(finally
(.delete (File. path)))))))
;; ---------------------------------------------------------------------------
;; Basic CRUD
;; ---------------------------------------------------------------------------
(deftest upsert-new-doc
(let [result (db/upsert! *ds* {:id "todo:1"
:value {:text "Buy milk" :tags #{:groceries}}
:base-version 0})]
(is (= :ok (:status result)))
(is (= 1 (:version result)))
(let [doc (db/get-doc *ds* "todo:1")]
(is (= "todo:1" (:id doc)))
(is (= {:text "Buy milk" :tags #{:groceries}} (:value doc)))
(is (= 1 (:version doc)))
(is (false? (:deleted doc))))))
(deftest upsert-update-doc
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
(let [result (db/upsert! *ds* {:id "todo:1" :value {:text "v2"} :base-version 1})]
(is (= :ok (:status result)))
(is (= 2 (:version result)))
(is (= {:text "v2"} (:value (db/get-doc *ds* "todo:1"))))))
(deftest upsert-conflict
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
(let [result (db/upsert! *ds* {:id "todo:1" :value {:text "bad"} :base-version 0})]
(is (= :conflict (:status result)))
(is (= 1 (:current-version result)))
;; Original doc unchanged
(is (= {:text "v1"} (:value (db/get-doc *ds* "todo:1"))))))
(deftest upsert-conflict-stale-version
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
(db/upsert! *ds* {:id "todo:1" :value {:text "v2"} :base-version 1})
(let [result (db/upsert! *ds* {:id "todo:1" :value {:text "bad"} :base-version 1})]
(is (= :conflict (:status result)))
(is (= 2 (:current-version result)))
(is (= {:text "v2"} (:value result)))))
;; ---------------------------------------------------------------------------
;; Delete
;; ---------------------------------------------------------------------------
(deftest soft-delete
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
(let [result (db/delete! *ds* {:id "todo:1" :base-version 1})]
(is (= :ok (:status result)))
(is (= 2 (:version result)))
(let [doc (db/get-doc *ds* "todo:1")]
(is (true? (:deleted doc)))
(is (= 2 (:version doc))))))
(deftest delete-conflict
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
(let [result (db/delete! *ds* {:id "todo:1" :base-version 0})]
(is (= :conflict (:status result)))
(is (= 1 (:current-version result)))))
;; ---------------------------------------------------------------------------
;; Queries
;; ---------------------------------------------------------------------------
(deftest docs-since-filters-by-group-and-time
(db/upsert! *ds* {:id "todo:1" :value {:text "a"} :base-version 0})
(Thread/sleep 10)
(let [t (System/currentTimeMillis)]
(Thread/sleep 10)
(db/upsert! *ds* {:id "todo:2" :value {:text "b"} :base-version 0})
(db/upsert! *ds* {:id "note:1" :value {:text "c"} :base-version 0})
(let [docs (db/docs-since *ds* "todo" t)]
(is (= 1 (count docs)))
(is (= "todo:2" (:id (first docs)))))))
(deftest all-docs-excludes-deleted
(db/upsert! *ds* {:id "todo:1" :value {:text "a"} :base-version 0})
(db/upsert! *ds* {:id "todo:2" :value {:text "b"} :base-version 0})
(db/delete! *ds* {:id "todo:1" :base-version 1})
(let [docs (db/all-docs *ds* "todo")]
(is (= 1 (count docs)))
(is (= "todo:2" (:id (first docs))))))
;; ---------------------------------------------------------------------------
;; Clojure type preservation
;; ---------------------------------------------------------------------------
(deftest preserves-clojure-types
(let [value {:keyword :hello
:set #{1 2 3}
:vec [1 "two" :three]
:uuid (java.util.UUID/randomUUID)
:inst #inst "2026-04-04T10:00:00Z"
:nested {:a {:b {:c 42}}}
:nil-val nil
:ratio 22/7
:symbol 'my-sym}]
(db/upsert! *ds* {:id "types:1" :value value :base-version 0})
(let [doc (db/get-doc *ds* "types:1")]
(is (= (:keyword value) (:keyword (:value doc))))
(is (= (:set value) (:set (:value doc))))
(is (= (:vec value) (:vec (:value doc))))
(is (= (:uuid value) (:uuid (:value doc))))
(is (= (:inst value) (:inst (:value doc))))
(is (= (:nested value) (:nested (:value doc))))
(is (nil? (:nil-val (:value doc))))
(is (= (:symbol value) (:symbol (:value doc)))))))
;; ---------------------------------------------------------------------------
;; Purge
;; ---------------------------------------------------------------------------
(deftest purge-deleted-removes-old
(db/upsert! *ds* {:id "todo:1" :value {:text "a"} :base-version 0})
(db/delete! *ds* {:id "todo:1" :base-version 1})
;; Wait a tick so the updated timestamp is in the past
(Thread/sleep 20)
;; Purge with a large max-age window — recent deletes are kept
(db/purge-deleted! *ds* 999999)
(is (some? (db/get-doc *ds* "todo:1")))
;; Purge with a tiny max-age — everything older than 10ms ago is removed
(db/purge-deleted! *ds* 10)
(is (nil? (db/get-doc *ds* "todo:1"))))

View File

@@ -0,0 +1,143 @@
(ns pocketbook.server-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[pocketbook.server :as server]
[pocketbook.transit :as t])
(:import [java.io File]
[java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers]))
(def ^:dynamic *server* nil)
(def ^:dynamic *port* 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-server-test" ".db"))
srv (server/start! {:port port :db-path db-path})]
(Thread/sleep 200) ;; let server start
(try
(binding [*server* srv *port* port]
(f))
(finally
(server/stop! srv)
(.delete (File. db-path)))))))
;; ---------------------------------------------------------------------------
;; HTTP helpers
;; ---------------------------------------------------------------------------
(def ^:private client (HttpClient/newHttpClient))
(defn- url [path & [query]]
(str "http://localhost:" *port* path (when query (str "?" query))))
(defn- get-transit [path query]
(let [req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path query)))
(.header "Accept" "application/transit+json")
(.GET)
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
{: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))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))
;; ---------------------------------------------------------------------------
;; Tests
;; ---------------------------------------------------------------------------
(deftest health-check
(let [req (-> (HttpRequest/newBuilder)
(.uri (URI. (url "/")))
(.GET)
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
(is (= 200 (.statusCode resp)))
(is (= "pocketbook ok" (.body resp)))))
(deftest push-and-pull
(testing "Push new documents"
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "Buy milk" :tags #{:groceries}} :base-version 0}
{:id "todo:2" :value {:text "Buy eggs"} :base-version 0}])]
(is (= 200 (:status resp)))
(is (every? #(= :ok (:status %)) (:body resp)))
(is (= 1 (:version (first (:body resp)))))))
(testing "Pull all docs"
(let [resp (get-transit "/sync" "group=todo&since=0")]
(is (= 200 (:status resp)))
(is (= 2 (count (:body resp))))
(is (= #{:groceries} (:tags (:value (first (:body resp))))))))
(testing "Update a doc"
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "Buy oat milk"} :base-version 1}])]
(is (= :ok (:status (first (:body resp)))))
(is (= 2 (:version (first (:body resp)))))))
(testing "Pull only recent changes"
(let [all (get-transit "/sync" "group=todo&since=0")
ts (:updated (second (:body all)))
recent (get-transit "/sync" (str "group=todo&since=" ts))]
;; Should get only todo:1 (updated) but not todo:2 (unchanged since ts)
;; (depends on timing, but at minimum we get at least 1)
(is (<= (count (:body recent)) 2)))))
(deftest push-conflict
(post-transit "/sync"
[{:id "todo:1" :value {:text "v1"} :base-version 0}])
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "stale"} :base-version 0}])]
(is (= :conflict (:status (first (:body resp)))))
(is (= 1 (:current-version (first (:body resp)))))))
(deftest push-delete
(post-transit "/sync"
[{:id "todo:del" :value {:text "delete me"} :base-version 0}])
(let [resp (post-transit "/sync"
[{:id "todo:del" :deleted true :base-version 1}])]
(is (= :ok (:status (first (:body resp))))))
(let [resp (get-transit "/sync" "group=todo&since=0")]
(is (some #(and (= "todo:del" (:id %)) (:deleted %)) (:body resp)))))
(deftest missing-group-param
(let [resp (get-transit "/sync" "since=0")]
(is (= 400 (:status resp)))))
(deftest type-preservation-over-wire
(let [uuid (java.util.UUID/randomUUID)
inst #inst "2026-04-04T10:00:00Z"
value {:keyword :hello
:set #{1 2 3}
:vec [1 "two" :three]
:uuid uuid
:inst inst
:nested {:a {:b 42}}}]
(post-transit "/sync"
[{:id "types:1" :value value :base-version 0}])
(let [resp (get-transit "/sync" "group=types&since=0")
pulled (:value (first (:body resp)))]
;; Transit preserves most types but nippy is used server-side
;; The round-trip is: transit-decode → nippy-freeze → nippy-thaw → transit-encode
(is (= :hello (:keyword pulled)))
(is (= #{1 2 3} (:set pulled)))
(is (= [1 "two" :three] (:vec pulled)))
(is (= uuid (:uuid pulled)))
(is (= inst (:inst pulled)))
(is (= {:a {:b 42}} (:nested pulled))))))

View File

@@ -0,0 +1,37 @@
(ns pocketbook.transit-test
(:require [clojure.test :refer [deftest is testing]]
[pocketbook.transit :as t]))
(deftest roundtrip-basic-types
(doseq [v [42
"hello"
:keyword
true
nil
[1 2 3]
{:a 1 :b {:c 2}}
#{1 2 3}]]
(is (= v (t/decode (t/encode v)))
(str "Roundtrip failed for: " (pr-str v)))))
(deftest roundtrip-clojure-types
(let [uuid (java.util.UUID/randomUUID)
inst #inst "2026-04-04T10:00:00Z"]
(is (= uuid (t/decode (t/encode uuid))))
(is (= inst (t/decode (t/encode inst))))))
(deftest roundtrip-complex-structure
(let [data [{:id "todo:1"
:value {:text "Buy milk" :tags #{:groceries :urgent}}
:version 3
:updated 1743760800000}
{:id "todo:2"
:deleted true
:version 5}]]
(is (= data (t/decode (t/encode data))))))
(deftest encode-str-roundtrip
(let [v {:hello "world" :nums [1 2 3]}
s (t/encode-str v)]
(is (string? s))
(is (= v (t/decode s)))))