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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.cpcache/
|
||||
.nrepl-port
|
||||
target/
|
||||
out/
|
||||
resources/public/js/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.cljs_nashorn_repl/
|
||||
node_modules/
|
||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Pocketbook
|
||||
|
||||
A Clojure-native synced atom. Offline-first key-value store with an `atom` interface that syncs to a SQLite-backed server over Transit.
|
||||
|
||||
```clojure
|
||||
(def conn (<! (pocketbook/open "my-app")))
|
||||
(def todos (pocketbook/synced-atom conn "todo" {:server "http://localhost:8090/sync"}))
|
||||
|
||||
(swap! todos assoc "todo:1" {:text "Buy milk" :tags #{:groceries}})
|
||||
@todos ;=> {"todo:1" {:text "Buy milk" :tags #{:groceries}}}
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- **Preserves Clojure types**: keywords, sets, UUIDs, instants — no lossy JSON
|
||||
- **Works offline**: reads/writes hit IndexedDB immediately, syncs when online
|
||||
- **Atom interface**: `swap!`, `deref`, `add-watch` — works with Reagent, Rum, or raw CLJS
|
||||
- **~500 lines**: client (~300) + server (~200), no opaque dependencies
|
||||
|
||||
## Quick start
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
clj -M:server
|
||||
# or: clj -M:server 8090 my-data.db
|
||||
```
|
||||
|
||||
Starts on `http://localhost:8090` with a SQLite file at `pocketbook.db`.
|
||||
|
||||
### Client (CLJS)
|
||||
|
||||
```clojure
|
||||
(ns my-app.core
|
||||
(:require [pocketbook.core :as pb]
|
||||
[cljs.core.async :refer [go <!]]))
|
||||
|
||||
(go
|
||||
(let [conn (<! (pb/open "my-app"))
|
||||
todos (pb/synced-atom conn "todo"
|
||||
{:server "http://localhost:8090/sync"})]
|
||||
(<! (pb/ready? todos))
|
||||
(swap! todos assoc "todo:1" {:text "Buy milk" :done false})
|
||||
(add-watch todos :log (fn [_ _ _ new] (println (count new) "todos")))))
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Server
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ SyncedAtom │ ── Transit/HTTP ──▶ │ http-kit │
|
||||
│ ↕ atom │ │ ↕ Transit │
|
||||
│ ↕ IndexedDB│ ◀── Transit/HTTP ── │ ↕ Nippy │
|
||||
│ (Transit) │ │ ↕ SQLite │
|
||||
└─────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Sync protocol
|
||||
|
||||
- **Pull**: `GET /sync?group=todo&since=<epoch-ms>` — returns changed docs
|
||||
- **Push**: `POST /sync` — sends local changes with version numbers
|
||||
- **Conflicts**: server rejects stale writes, client accepts server value
|
||||
|
||||
## Auth
|
||||
|
||||
Optional token-based auth:
|
||||
|
||||
```clojure
|
||||
(server/start! {:port 8090
|
||||
:users {"alice" {:token "abc123" :groups #{"todo" "settings"}}
|
||||
"bob" {:token "def456" :groups #{"todo"}}}})
|
||||
```
|
||||
|
||||
Client passes token:
|
||||
|
||||
```clojure
|
||||
(pb/synced-atom conn "todo" {:server "http://localhost:8090/sync"
|
||||
:token "abc123"})
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Layer | Library | Purpose |
|
||||
|--------|---------|---------|
|
||||
| Server | next.jdbc + sqlite-jdbc | SQLite storage |
|
||||
| Server | nippy | Binary serialization |
|
||||
| Server | transit-clj | Wire format |
|
||||
| Server | http-kit | HTTP server |
|
||||
| Client | transit-cljs | Serialization (IDB + wire) |
|
||||
| Client | core.async | Sync coordination |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# All server tests
|
||||
clj -M:dev -e '(require (quote pocketbook.db-test) (quote pocketbook.transit-test) (quote pocketbook.server-test) (quote pocketbook.auth-test)) (clojure.test/run-all-tests #"pocketbook\..*")'
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
5
build.edn
Normal file
5
build.edn
Normal file
@@ -0,0 +1,5 @@
|
||||
{:main pocketbook.example
|
||||
:output-to "resources/public/js/main.js"
|
||||
:output-dir "resources/public/js/out"
|
||||
:asset-path "js/out"
|
||||
:optimizations :none}
|
||||
26
deps.edn
Normal file
26
deps.edn
Normal file
@@ -0,0 +1,26 @@
|
||||
{:paths ["src" "resources"]
|
||||
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
|
||||
http-kit/http-kit {:mvn/version "2.8.0"}
|
||||
com.cognitect/transit-clj {:mvn/version "1.0.333"}
|
||||
com.taoensso/nippy {:mvn/version "3.4.2"}
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.47.1.0"}
|
||||
ring/ring-core {:mvn/version "1.13.0"}}
|
||||
|
||||
:aliases
|
||||
{:dev {:extra-paths ["test"]
|
||||
:extra-deps {io.github.cognitect-labs/test-runner
|
||||
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}}
|
||||
:test {:main-opts ["-m" "cognitect.test-runner"]}
|
||||
:server {:main-opts ["-m" "pocketbook.server"]}
|
||||
|
||||
;; ClojureScript client build
|
||||
:cljs {:extra-deps {org.clojure/clojurescript {:mvn/version "1.11.132"}
|
||||
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
|
||||
org.clojure/core.async {:mvn/version "1.7.701"}}
|
||||
:main-opts ["-m" "cljs.main" "-co" "build.edn" "-c"]}
|
||||
|
||||
:cljs-dev {:extra-deps {org.clojure/clojurescript {:mvn/version "1.11.132"}
|
||||
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
|
||||
org.clojure/core.async {:mvn/version "1.7.701"}}
|
||||
:main-opts ["-m" "cljs.main" "-co" "build.edn" "-w" "src" "-c"]}}}
|
||||
27
resources/public/index.html
Normal file
27
resources/public/index.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pocketbook Example</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
||||
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
|
||||
#add-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
#add-form input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
|
||||
#add-form button { padding: 0.5rem 1rem; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
|
||||
#add-form button:hover { background: #1d4ed8; }
|
||||
ul { list-style: none; }
|
||||
li { padding: 0.5rem 0; border-bottom: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; }
|
||||
label { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
||||
.del-btn { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 1.1rem; padding: 0 0.25rem; }
|
||||
.del-btn:hover { color: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">Loading...</div>
|
||||
<script src="js/out/goog/base.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
355
src/pocketbook/core.cljs
Normal file
355
src/pocketbook/core.cljs
Normal file
@@ -0,0 +1,355 @@
|
||||
(ns pocketbook.core
|
||||
"Pocketbook: a Clojure-native synced atom.
|
||||
|
||||
Usage:
|
||||
(def conn (pocketbook/open \"my-app\"))
|
||||
(def todos (pocketbook/synced-atom conn \"todo\"
|
||||
{:server \"http://localhost:8090/sync\"}))
|
||||
(go (<! (ready? todos))
|
||||
(swap! todos assoc \"todo:1\" {:text \"Buy milk\"}))
|
||||
@todos ;=> {\"todo:1\" {:text \"Buy milk\"}}
|
||||
"
|
||||
(:require [pocketbook.idb :as idb]
|
||||
[pocketbook.sync :as sync]
|
||||
[cljs.core.async :refer [go go-loop <! >! chan put! close! timeout alts!]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Connection (IDB handle)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn open
|
||||
"Open a Pocketbook connection (IndexedDB database).
|
||||
Returns a channel yielding the connection map."
|
||||
[db-name]
|
||||
(let [ch (chan 1)]
|
||||
(go
|
||||
(let [db (<! (idb/open db-name))]
|
||||
(>! ch {:db db :db-name db-name})
|
||||
(close! ch)))
|
||||
ch))
|
||||
|
||||
(defn close!
|
||||
"Close a Pocketbook connection."
|
||||
[{:keys [db atoms]}]
|
||||
;; Stop all sync loops
|
||||
(doseq [[_ sa] @(or atoms (atom {}))]
|
||||
(when-let [stop (:stop-fn sa)]
|
||||
(stop)))
|
||||
(idb/close! db))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Synced Atom — implements IAtom semantics
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftype SyncedAtom [group ;; string prefix, e.g. "todo"
|
||||
conn ;; {:db idb, ...}
|
||||
cache ;; atom containing {id -> value}
|
||||
versions ;; atom containing {id -> version}
|
||||
pending ;; atom containing #{id} — unsynced ids
|
||||
server-opts ;; {:server url :token str} or nil
|
||||
last-sync ;; atom containing epoch ms
|
||||
ready-ch ;; channel, closed when initial load complete
|
||||
stop-ch ;; channel to signal stop
|
||||
cleanup-fn ;; atom holding connectivity cleanup fn
|
||||
sync-interval ;; ms
|
||||
_meta] ;; metadata atom
|
||||
|
||||
IAtom
|
||||
|
||||
IDeref
|
||||
(-deref [_]
|
||||
@cache)
|
||||
|
||||
IReset
|
||||
(-reset! [_ new-val]
|
||||
;; Replace the entire cache (all docs in group)
|
||||
(let [old @cache]
|
||||
(reset! cache new-val)
|
||||
;; Track which docs changed/added/removed
|
||||
(let [all-keys (into (set (keys old)) (keys new-val))]
|
||||
(doseq [k all-keys]
|
||||
(when (not= (get old k) (get new-val k))
|
||||
(swap! pending conj k)
|
||||
;; Write to IDB
|
||||
(let [v (get new-val k)]
|
||||
(if (nil? v)
|
||||
;; Doc was dissoc'd — mark deleted
|
||||
(idb/put-doc! (:db conn)
|
||||
{:id k :value nil :version (get @versions k 0)
|
||||
:updated (.now js/Date) :deleted true :synced false})
|
||||
(idb/put-doc! (:db conn)
|
||||
{:id k :value v :version (get @versions k 0)
|
||||
:updated (.now js/Date) :deleted false :synced false}))))))
|
||||
new-val))
|
||||
|
||||
ISwap
|
||||
(-swap! [o f]
|
||||
(-reset! o (f @cache)))
|
||||
(-swap! [o f a]
|
||||
(-reset! o (f @cache a)))
|
||||
(-swap! [o f a b]
|
||||
(-reset! o (f @cache a b)))
|
||||
(-swap! [o f a b xs]
|
||||
(-reset! o (apply f @cache a b xs)))
|
||||
|
||||
IWatchable
|
||||
(-add-watch [_ key f]
|
||||
(add-watch cache key f))
|
||||
(-remove-watch [_ key]
|
||||
(remove-watch cache key))
|
||||
(-notify-watches [_ old new]
|
||||
;; Delegated to the inner atom
|
||||
nil)
|
||||
|
||||
IMeta
|
||||
(-meta [_] @_meta)
|
||||
|
||||
IWithMeta
|
||||
(-with-meta [_ m] (reset! _meta m))
|
||||
|
||||
IPrintWithWriter
|
||||
(-pr-writer [_ writer opts]
|
||||
(-write writer (str "#<SyncedAtom[" group "] " (count @cache) " docs>"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Internal helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- prefix-str [group]
|
||||
(str group ":"))
|
||||
|
||||
(defn- now-ms []
|
||||
(.now js/Date))
|
||||
|
||||
(defn- doc-in-group? [group id]
|
||||
(clojure.string/starts-with? id (prefix-str group)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; IDB ↔ Atom sync
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- load-from-idb!
|
||||
"Load all docs for the group from IndexedDB into the atom.
|
||||
Returns a channel that closes when done."
|
||||
[sa]
|
||||
(let [ch (chan 1)]
|
||||
(go
|
||||
(let [prefix (prefix-str (.-group sa))
|
||||
docs (<! (idb/get-all-by-prefix (:db (.-conn sa)) prefix))
|
||||
state (into {}
|
||||
(comp
|
||||
(remove :deleted)
|
||||
(map (fn [d] [(:id d) (:value d)])))
|
||||
docs)
|
||||
vers (into {}
|
||||
(map (fn [d] [(:id d) (:version d)]))
|
||||
docs)]
|
||||
(reset! (.-cache sa) state)
|
||||
(reset! (.-versions sa) vers)
|
||||
;; Load last-sync from IDB meta
|
||||
(let [ls (<! (idb/get-meta (:db (.-conn sa))
|
||||
(str "last-sync:" (.-group sa))))]
|
||||
(reset! (.-last_sync sa) (or ls 0)))
|
||||
(put! ch true)
|
||||
(close! ch)))
|
||||
ch))
|
||||
|
||||
(defn- write-doc-to-idb!
|
||||
"Persist a single doc to IDB. Returns a channel."
|
||||
[sa id value deleted?]
|
||||
(idb/put-doc! (:db (.-conn sa))
|
||||
{:id id
|
||||
:value value
|
||||
:version (get @(.-versions sa) id 0)
|
||||
:updated (now-ms)
|
||||
:deleted (boolean deleted?)
|
||||
:synced false}))
|
||||
|
||||
(defn- mark-synced!
|
||||
"Mark a doc as synced in IDB and update its version."
|
||||
[sa id version]
|
||||
(swap! (.-versions sa) assoc id version)
|
||||
(swap! (.-pending sa) disj id)
|
||||
(let [value (get @(.-cache sa) id)]
|
||||
(idb/put-doc! (:db (.-conn sa))
|
||||
{:id id
|
||||
:value value
|
||||
:version version
|
||||
:updated (now-ms)
|
||||
:deleted (nil? value)
|
||||
:synced true})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Sync logic
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- do-pull!
|
||||
"Pull changes from server, merge into atom + IDB."
|
||||
[sa]
|
||||
(go
|
||||
(when-let [opts (.-server_opts sa)]
|
||||
(let [since @(.-last_sync sa)
|
||||
result (<! (sync/pull! opts (.-group sa) since))]
|
||||
(when (:ok result)
|
||||
(let [docs (:docs result)
|
||||
max-ts (reduce max @(.-last_sync sa)
|
||||
(map :updated docs))]
|
||||
;; Merge each doc into cache
|
||||
(doseq [doc docs]
|
||||
(let [id (:id doc)]
|
||||
;; Only apply if server version > local version
|
||||
(when (> (:version doc) (get @(.-versions sa) id 0))
|
||||
(if (:deleted doc)
|
||||
(do
|
||||
(swap! (.-cache sa) dissoc id)
|
||||
(swap! (.-versions sa) assoc id (:version doc))
|
||||
(idb/put-doc! (:db (.-conn sa))
|
||||
{:id id :value nil :version (:version doc)
|
||||
:updated (:updated doc) :deleted true :synced true}))
|
||||
(do
|
||||
(swap! (.-cache sa) assoc id (:value doc))
|
||||
(swap! (.-versions sa) assoc id (:version doc))
|
||||
(idb/put-doc! (:db (.-conn sa))
|
||||
{:id id :value (:value doc) :version (:version doc)
|
||||
:updated (:updated doc) :deleted false :synced true}))))))
|
||||
;; Update last-sync
|
||||
(reset! (.-last_sync sa) max-ts)
|
||||
(idb/set-meta! (:db (.-conn sa))
|
||||
(str "last-sync:" (.-group sa)) max-ts)))
|
||||
true)))))
|
||||
|
||||
(defn- do-push!
|
||||
"Push all unsynced local docs to the server."
|
||||
[sa]
|
||||
(go
|
||||
(when-let [opts (.-server_opts sa)]
|
||||
(let [pending-ids @(.-pending sa)]
|
||||
(when (seq pending-ids)
|
||||
(let [docs (mapv (fn [id]
|
||||
(let [v (get @(.-cache sa) id)]
|
||||
(if (nil? v)
|
||||
{:id id :deleted true
|
||||
:base-version (get @(.-versions sa) id 0)}
|
||||
{:id id :value v
|
||||
:base-version (get @(.-versions sa) id 0)})))
|
||||
pending-ids)
|
||||
result (<! (sync/push! opts docs))]
|
||||
(when (:ok result)
|
||||
(doseq [r (:results result)]
|
||||
(case (:status r)
|
||||
:ok
|
||||
(<! (mark-synced! sa (:id r) (:version r)))
|
||||
|
||||
:conflict
|
||||
;; On conflict, accept server value and mark synced
|
||||
(do
|
||||
(when (:value r)
|
||||
(swap! (.-cache sa) assoc (:id r) (:value r)))
|
||||
(swap! (.-versions sa) assoc (:id r) (:current-version r))
|
||||
(swap! (.-pending sa) disj (:id r))
|
||||
(idb/put-doc! (:db (.-conn sa))
|
||||
{:id (:id r)
|
||||
:value (or (:value r) (get @(.-cache sa) (:id r)))
|
||||
:version (:current-version r)
|
||||
:updated (now-ms)
|
||||
:deleted false
|
||||
:synced true}))
|
||||
|
||||
;; Unknown status, log
|
||||
(js/console.warn "Unknown push result:" (pr-str r))))
|
||||
true)))))))
|
||||
|
||||
(defn- do-sync!
|
||||
"Run a full sync cycle: pull then push."
|
||||
[sa]
|
||||
(go
|
||||
(when (sync/online?)
|
||||
(<! (do-pull! sa))
|
||||
(<! (do-push! sa)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Sync loop
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- start-sync-loop!
|
||||
"Start the background sync loop. Returns a stop function."
|
||||
[sa]
|
||||
(let [stop-ch (.-stop_ch sa)
|
||||
interval (.-sync_interval sa)]
|
||||
;; Periodic sync
|
||||
(go-loop []
|
||||
(let [[_ ch] (alts! [stop-ch (timeout interval)])]
|
||||
(when-not (= ch stop-ch)
|
||||
(<! (do-sync! sa))
|
||||
(recur))))
|
||||
;; Online/offline handler
|
||||
(let [cleanup (sync/on-connectivity-change
|
||||
(fn [] ; online
|
||||
(go (<! (do-sync! sa))))
|
||||
(fn [] ; offline
|
||||
nil))]
|
||||
(reset! (.-cleanup_fn sa) cleanup))
|
||||
;; Return stop function
|
||||
(fn []
|
||||
(put! stop-ch :stop)
|
||||
(when-let [cleanup @(.-cleanup_fn sa)]
|
||||
(cleanup)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Public API
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn synced-atom
|
||||
"Create a synced atom for a document group.
|
||||
|
||||
Options:
|
||||
:server — server URL (e.g. \"http://localhost:8090/sync\")
|
||||
:token — auth token
|
||||
:cache — custom atom to use (e.g. reagent/atom). Default: cljs.core/atom
|
||||
:interval — sync interval in ms (default 30000)"
|
||||
[conn group & [{:keys [server token cache interval]
|
||||
:or {interval 30000}}]]
|
||||
(let [cache-atom (or cache (atom {}))
|
||||
versions (atom {})
|
||||
pending (atom #{})
|
||||
server-opts (when server {:server server :token token})
|
||||
last-sync (atom 0)
|
||||
ready-ch (chan 1)
|
||||
stop-ch (chan 1)
|
||||
cleanup-fn (atom nil)
|
||||
meta-atom (atom nil)
|
||||
sa (SyncedAtom. group conn cache-atom versions pending
|
||||
server-opts last-sync ready-ch stop-ch
|
||||
cleanup-fn interval meta-atom)]
|
||||
;; Load from IDB, then start sync
|
||||
(go
|
||||
(<! (load-from-idb! sa))
|
||||
(put! ready-ch true)
|
||||
(close! ready-ch)
|
||||
;; Initial sync
|
||||
(when server-opts
|
||||
(<! (do-sync! sa))
|
||||
(start-sync-loop! sa)))
|
||||
sa))
|
||||
|
||||
(defn ready?
|
||||
"Returns a channel that yields true when the atom has finished loading from IDB."
|
||||
[sa]
|
||||
(.-ready_ch sa))
|
||||
|
||||
(defn sync-now!
|
||||
"Trigger an immediate sync cycle. Returns a channel."
|
||||
[sa]
|
||||
(do-sync! sa))
|
||||
|
||||
(defn pending-count
|
||||
"Number of documents waiting to be synced."
|
||||
[sa]
|
||||
(count @(.-pending sa)))
|
||||
|
||||
(defn destroy!
|
||||
"Stop the sync loop and clean up. Does not close the IDB connection."
|
||||
[sa]
|
||||
(put! (.-stop_ch sa) :stop)
|
||||
(when-let [cleanup @(.-cleanup_fn sa)]
|
||||
(cleanup)))
|
||||
177
src/pocketbook/db.clj
Normal file
177
src/pocketbook/db.clj
Normal file
@@ -0,0 +1,177 @@
|
||||
(ns pocketbook.db
|
||||
"SQLite storage layer with Nippy serialization.
|
||||
Stores documents as binary blobs preserving all Clojure types."
|
||||
(:require [next.jdbc :as jdbc]
|
||||
[next.jdbc.result-set :as rs]
|
||||
[taoensso.nippy :as nippy])
|
||||
(:import [java.time Instant]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Connection
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn open
|
||||
"Open a SQLite database at `path`, create tables if needed.
|
||||
Returns a JDBC datasource."
|
||||
[path]
|
||||
(let [ds (jdbc/get-datasource {:dbtype "sqlite" :dbname path})]
|
||||
(jdbc/execute! ds ["PRAGMA journal_mode=WAL"])
|
||||
(jdbc/execute! ds ["PRAGMA foreign_keys=ON"])
|
||||
(jdbc/execute! ds ["CREATE TABLE IF NOT EXISTS docs (
|
||||
id TEXT PRIMARY KEY,
|
||||
value BLOB NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated INTEGER NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0)"])
|
||||
(jdbc/execute! ds ["CREATE INDEX IF NOT EXISTS idx_docs_updated
|
||||
ON docs(updated)"])
|
||||
ds))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- now-ms []
|
||||
(.toEpochMilli (Instant/now)))
|
||||
|
||||
(defn- freeze [v]
|
||||
(nippy/freeze v))
|
||||
|
||||
(defn- thaw [^bytes bs]
|
||||
(when bs (nippy/thaw bs)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reads
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn get-doc
|
||||
"Fetch a single document by id. Returns nil if not found."
|
||||
[ds id]
|
||||
(let [row (jdbc/execute-one! ds
|
||||
["SELECT * FROM docs WHERE id = ?" id]
|
||||
{:builder-fn rs/as-unqualified-maps})]
|
||||
(when row
|
||||
{:id (:id row)
|
||||
:value (thaw (:value row))
|
||||
:version (:version row)
|
||||
:updated (:updated row)
|
||||
:deleted (= 1 (:deleted row))})))
|
||||
|
||||
(defn docs-since
|
||||
"Return all docs in `group` updated after `since` (epoch ms).
|
||||
Group is matched as prefix: group 'todo' matches 'todo:*'."
|
||||
[ds group since]
|
||||
(let [prefix (str group ":")
|
||||
rows (jdbc/execute! ds
|
||||
["SELECT * FROM docs
|
||||
WHERE id LIKE ? AND updated > ?
|
||||
ORDER BY updated ASC"
|
||||
(str prefix "%") since]
|
||||
{:builder-fn rs/as-unqualified-maps})]
|
||||
(mapv (fn [row]
|
||||
{:id (:id row)
|
||||
:value (when-not (= 1 (:deleted row))
|
||||
(thaw (:value row)))
|
||||
:version (:version row)
|
||||
:updated (:updated row)
|
||||
:deleted (= 1 (:deleted row))})
|
||||
rows)))
|
||||
|
||||
(defn all-docs
|
||||
"Return all non-deleted docs in a group."
|
||||
[ds group]
|
||||
(let [prefix (str group ":")
|
||||
rows (jdbc/execute! ds
|
||||
["SELECT * FROM docs
|
||||
WHERE id LIKE ? AND deleted = 0
|
||||
ORDER BY id ASC"
|
||||
(str prefix "%")]
|
||||
{:builder-fn rs/as-unqualified-maps})]
|
||||
(mapv (fn [row]
|
||||
{:id (:id row)
|
||||
:value (thaw (:value row))
|
||||
:version (:version row)
|
||||
:updated (:updated row)
|
||||
:deleted false})
|
||||
rows)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Writes
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn upsert!
|
||||
"Insert or update a document.
|
||||
- If `base-version` is 0, this is a new doc (insert with version 1).
|
||||
- If `base-version` matches current version, update (bump version).
|
||||
- Otherwise, return {:status :conflict ...}.
|
||||
Returns {:status :ok :version N} or {:status :conflict :current-version N :value V}."
|
||||
[ds {:keys [id value base-version]}]
|
||||
(jdbc/with-transaction [tx ds]
|
||||
(let [existing (get-doc tx id)
|
||||
ts (now-ms)]
|
||||
(cond
|
||||
;; New document
|
||||
(and (nil? existing) (= 0 base-version))
|
||||
(do (jdbc/execute! tx
|
||||
["INSERT INTO docs (id, value, version, updated, deleted) VALUES (?, ?, 1, ?, 0)"
|
||||
id (freeze value) ts])
|
||||
{:id id :status :ok :version 1 :updated ts})
|
||||
|
||||
;; New doc but already exists
|
||||
(and existing (= 0 base-version))
|
||||
{:id id :status :conflict
|
||||
:current-version (:version existing)
|
||||
:value (:value existing)}
|
||||
|
||||
;; Update existing — version match
|
||||
(and existing (= base-version (:version existing)))
|
||||
(let [new-version (inc (:version existing))]
|
||||
(jdbc/execute! tx
|
||||
["UPDATE docs SET value = ?, version = ?, updated = ?, deleted = 0
|
||||
WHERE id = ?"
|
||||
(freeze value) new-version ts id])
|
||||
{:id id :status :ok :version new-version :updated ts})
|
||||
|
||||
;; Update existing — version mismatch (conflict)
|
||||
existing
|
||||
{:id id :status :conflict
|
||||
:current-version (:version existing)
|
||||
:value (:value existing)}
|
||||
|
||||
;; Trying to update non-existent doc
|
||||
:else
|
||||
{:id id :status :conflict :current-version 0 :value nil}))))
|
||||
|
||||
(defn delete!
|
||||
"Soft-delete a document (set deleted=1, bump version).
|
||||
Same version semantics as upsert!."
|
||||
[ds {:keys [id base-version]}]
|
||||
(jdbc/with-transaction [tx ds]
|
||||
(let [existing (get-doc tx id)
|
||||
ts (now-ms)]
|
||||
(cond
|
||||
(nil? existing)
|
||||
{:id id :status :ok :version 0 :updated ts}
|
||||
|
||||
(= base-version (:version existing))
|
||||
(let [new-version (inc (:version existing))]
|
||||
(jdbc/execute! tx
|
||||
["UPDATE docs SET deleted = 1, version = ?, updated = ? WHERE id = ?"
|
||||
new-version ts id])
|
||||
{:id id :status :ok :version new-version :updated ts})
|
||||
|
||||
:else
|
||||
{:id id :status :conflict
|
||||
:current-version (:version existing)
|
||||
:value (:value existing)}))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Maintenance
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn purge-deleted!
|
||||
"Permanently remove soft-deleted docs older than `max-age-ms`."
|
||||
[ds max-age-ms]
|
||||
(let [cutoff (- (now-ms) max-age-ms)]
|
||||
(jdbc/execute! ds
|
||||
["DELETE FROM docs WHERE deleted = 1 AND updated < ?" cutoff])))
|
||||
75
src/pocketbook/example.cljs
Normal file
75
src/pocketbook/example.cljs
Normal file
@@ -0,0 +1,75 @@
|
||||
(ns pocketbook.example
|
||||
"Example: a simple todo app using Pocketbook."
|
||||
(:require [pocketbook.core :as pb]
|
||||
[cljs.core.async :refer [go <!]]))
|
||||
|
||||
(defn- render-todos! [todos-atom]
|
||||
(let [todos @todos-atom
|
||||
container (js/document.getElementById "app")]
|
||||
(set! (.-innerHTML container)
|
||||
(str "<h1>Pocketbook Todos (" (count todos) ")</h1>"
|
||||
"<div id='add-form'>"
|
||||
"<input id='new-todo' type='text' placeholder='New todo...' />"
|
||||
"<button id='add-btn'>Add</button>"
|
||||
"</div>"
|
||||
"<ul>"
|
||||
(apply str
|
||||
(for [[id doc] (sort-by key todos)]
|
||||
(str "<li>"
|
||||
"<label>"
|
||||
"<input type='checkbox' data-id='" id "' "
|
||||
(when (:done doc) "checked") " /> "
|
||||
"<span" (when (:done doc) " style='text-decoration:line-through'") ">"
|
||||
(:text doc)
|
||||
"</span>"
|
||||
"</label>"
|
||||
" <button class='del-btn' data-id='" id "'>✕</button>"
|
||||
"</li>")))
|
||||
"</ul>"
|
||||
"<p style='color:#888;font-size:12px'>"
|
||||
"Pending sync: " (pb/pending-count todos-atom)
|
||||
"</p>"))))
|
||||
|
||||
(defn- setup-handlers! [todos-atom]
|
||||
;; We re-setup after each render
|
||||
(when-let [btn (js/document.getElementById "add-btn")]
|
||||
(.addEventListener btn "click"
|
||||
(fn [_]
|
||||
(let [input (js/document.getElementById "new-todo")
|
||||
text (.-value input)]
|
||||
(when (seq text)
|
||||
(let [id (str "todo:" (random-uuid))]
|
||||
(swap! todos-atom assoc id {:text text :done false})
|
||||
(set! (.-value input) "")))))))
|
||||
;; Checkbox toggles
|
||||
(doseq [cb (array-seq (.querySelectorAll js/document "input[type=checkbox]"))]
|
||||
(.addEventListener cb "change"
|
||||
(fn [e]
|
||||
(let [id (.-id (.-dataset (.-target e)))]
|
||||
(swap! todos-atom update-in [id :done] not)))))
|
||||
;; Delete buttons
|
||||
(doseq [btn (array-seq (.querySelectorAll js/document ".del-btn"))]
|
||||
(.addEventListener btn "click"
|
||||
(fn [e]
|
||||
(let [id (.-id (.-dataset (.-target e)))]
|
||||
(swap! todos-atom dissoc id))))))
|
||||
|
||||
(defn ^:export init []
|
||||
(go
|
||||
(let [conn (<! (pb/open "pocketbook-example"))
|
||||
todos (pb/synced-atom conn "todo"
|
||||
{:server "http://localhost:8090/sync"})]
|
||||
;; Wait for initial load
|
||||
(<! (pb/ready? todos))
|
||||
;; Render
|
||||
(render-todos! todos)
|
||||
(setup-handlers! todos)
|
||||
;; Re-render on changes
|
||||
(add-watch todos :render
|
||||
(fn [_ _ _ _]
|
||||
(render-todos! todos)
|
||||
(setup-handlers! todos)))
|
||||
(js/console.log "Pocketbook example loaded!" (count @todos) "todos"))))
|
||||
|
||||
;; Auto-init
|
||||
(init)
|
||||
222
src/pocketbook/idb.cljs
Normal file
222
src/pocketbook/idb.cljs
Normal file
@@ -0,0 +1,222 @@
|
||||
(ns pocketbook.idb
|
||||
"IndexedDB wrapper with Transit serialization.
|
||||
Stores documents as Transit-encoded strings preserving all Clojure types."
|
||||
(:require [cognitect.transit :as t]
|
||||
[cljs.core.async :refer [chan put! close!]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Transit
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private writer (t/writer :json))
|
||||
(def ^:private reader (t/reader :json))
|
||||
|
||||
(defn- encode [v]
|
||||
(t/write writer v))
|
||||
|
||||
(defn- decode [s]
|
||||
(when s (t/read reader s)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; IDB operations
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn open
|
||||
"Open an IndexedDB database. Returns a channel that yields the db."
|
||||
[db-name]
|
||||
(let [ch (chan 1)
|
||||
req (.open js/indexedDB db-name 1)]
|
||||
(set! (.-onupgradeneeded req)
|
||||
(fn [e]
|
||||
(let [db (.-result (.-target e))]
|
||||
;; Main document store
|
||||
(when-not (.contains (.-objectStoreNames db) "docs")
|
||||
(let [store (.createObjectStore db "docs" #js {:keyPath "id"})]
|
||||
(.createIndex store "synced" "synced" #js {:unique false})
|
||||
(.createIndex store "updated" "updated" #js {:unique false})))
|
||||
;; Metadata store (last-sync timestamps, etc.)
|
||||
(when-not (.contains (.-objectStoreNames db) "meta")
|
||||
(.createObjectStore db "meta" #js {:keyPath "key"})))))
|
||||
(set! (.-onsuccess req)
|
||||
(fn [e]
|
||||
(put! ch (.-result (.-target e)))
|
||||
(close! ch)))
|
||||
(set! (.-onerror req)
|
||||
(fn [e]
|
||||
(js/console.error "IDB open error:" e)
|
||||
(close! ch)))
|
||||
ch))
|
||||
|
||||
(defn- tx
|
||||
"Start an IDB transaction. mode is :readonly or :readwrite."
|
||||
[db store-name mode]
|
||||
(let [mode-str (case mode :readonly "readonly" :readwrite "readwrite")]
|
||||
(.transaction db #js [store-name] mode-str)))
|
||||
|
||||
(defn put-doc!
|
||||
"Write a document to IDB. Returns a channel that closes on success.
|
||||
doc should be: {:id str :value any :version int :updated int :deleted bool :synced bool}"
|
||||
[db doc]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "docs" :readwrite)
|
||||
store (.objectStore txn "docs")
|
||||
;; Serialize the value to Transit, keep metadata as-is
|
||||
obj #js {:id (:id doc)
|
||||
:value (encode (:value doc))
|
||||
:version (:version doc 0)
|
||||
:updated (:updated doc 0)
|
||||
:deleted (boolean (:deleted doc false))
|
||||
:synced (boolean (:synced doc false))}
|
||||
req (.put store obj)]
|
||||
(set! (.-onsuccess req) (fn [_] (put! ch true) (close! ch)))
|
||||
(set! (.-onerror req) (fn [e] (js/console.error "IDB put error:" e) (close! ch)))
|
||||
ch))
|
||||
|
||||
(defn put-docs!
|
||||
"Write multiple documents in a single transaction. Returns a channel."
|
||||
[db docs]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "docs" :readwrite)
|
||||
store (.objectStore txn "docs")]
|
||||
(doseq [doc docs]
|
||||
(let [obj #js {:id (:id doc)
|
||||
:value (encode (:value doc))
|
||||
:version (:version doc 0)
|
||||
:updated (:updated doc 0)
|
||||
:deleted (boolean (:deleted doc false))
|
||||
:synced (boolean (:synced doc false))}]
|
||||
(.put store obj)))
|
||||
(set! (.-oncomplete txn) (fn [_] (put! ch true) (close! ch)))
|
||||
(set! (.-onerror txn) (fn [e] (js/console.error "IDB batch put error:" e) (close! ch)))
|
||||
ch))
|
||||
|
||||
(defn get-doc
|
||||
"Read a single document by id. Returns a channel yielding the doc or nil."
|
||||
[db id]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "docs" :readonly)
|
||||
store (.objectStore txn "docs")
|
||||
req (.get store id)]
|
||||
(set! (.-onsuccess req)
|
||||
(fn [e]
|
||||
(let [result (.-result (.-target e))]
|
||||
(put! ch (when result
|
||||
{:id (.-id result)
|
||||
:value (decode (.-value result))
|
||||
:version (.-version result)
|
||||
:updated (.-updated result)
|
||||
:deleted (.-deleted result)
|
||||
:synced (.-synced result)}))
|
||||
(close! ch))))
|
||||
(set! (.-onerror req)
|
||||
(fn [e] (js/console.error "IDB get error:" e) (close! ch)))
|
||||
ch))
|
||||
|
||||
(defn get-all-by-prefix
|
||||
"Get all documents whose id starts with prefix (e.g., 'todo:').
|
||||
Returns a channel yielding a vector of docs."
|
||||
[db prefix]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "docs" :readonly)
|
||||
store (.objectStore txn "docs")
|
||||
range (.bound js/IDBKeyRange prefix (str prefix "\uffff"))
|
||||
req (.openCursor store range)
|
||||
docs (atom [])]
|
||||
(set! (.-onsuccess req)
|
||||
(fn [e]
|
||||
(let [cursor (.-result (.-target e))]
|
||||
(if cursor
|
||||
(let [val (.-value cursor)]
|
||||
(swap! docs conj
|
||||
{:id (.-id val)
|
||||
:value (decode (.-value val))
|
||||
:version (.-version val)
|
||||
:updated (.-updated val)
|
||||
:deleted (.-deleted val)
|
||||
:synced (.-synced val)})
|
||||
(.continue cursor))
|
||||
(do
|
||||
(put! ch @docs)
|
||||
(close! ch))))))
|
||||
(set! (.-onerror req)
|
||||
(fn [e] (js/console.error "IDB cursor error:" e) (close! ch)))
|
||||
ch))
|
||||
|
||||
(defn get-unsynced
|
||||
"Get all documents with synced=false. Returns a channel yielding a vector."
|
||||
[db]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "docs" :readonly)
|
||||
store (.objectStore txn "docs")
|
||||
idx (.index store "synced")
|
||||
req (.openCursor idx (.only js/IDBKeyRange false))
|
||||
docs (atom [])]
|
||||
(set! (.-onsuccess req)
|
||||
(fn [e]
|
||||
(let [cursor (.-result (.-target e))]
|
||||
(if cursor
|
||||
(let [val (.-value cursor)]
|
||||
(swap! docs conj
|
||||
{:id (.-id val)
|
||||
:value (decode (.-value val))
|
||||
:version (.-version val)
|
||||
:updated (.-updated val)
|
||||
:deleted (.-deleted val)
|
||||
:synced false})
|
||||
(.continue cursor))
|
||||
(do
|
||||
(put! ch @docs)
|
||||
(close! ch))))))
|
||||
(set! (.-onerror req)
|
||||
(fn [e] (js/console.error "IDB unsynced error:" e) (close! ch)))
|
||||
ch))
|
||||
|
||||
(defn delete-doc!
|
||||
"Delete a document from IDB by id. Returns a channel."
|
||||
[db id]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "docs" :readwrite)
|
||||
store (.objectStore txn "docs")
|
||||
req (.delete store id)]
|
||||
(set! (.-onsuccess req) (fn [_] (put! ch true) (close! ch)))
|
||||
(set! (.-onerror req) (fn [e] (js/console.error "IDB delete error:" e) (close! ch)))
|
||||
ch))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Metadata
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn get-meta
|
||||
"Get a metadata value by key. Returns a channel."
|
||||
[db key]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "meta" :readonly)
|
||||
store (.objectStore txn "meta")
|
||||
req (.get store key)]
|
||||
(set! (.-onsuccess req)
|
||||
(fn [e]
|
||||
(let [result (.-result (.-target e))]
|
||||
(put! ch (when result (.-value result)))
|
||||
(close! ch))))
|
||||
(set! (.-onerror req) (fn [_] (close! ch)))
|
||||
ch))
|
||||
|
||||
(defn set-meta!
|
||||
"Set a metadata value. Returns a channel."
|
||||
[db key value]
|
||||
(let [ch (chan 1)
|
||||
txn (tx db "meta" :readwrite)
|
||||
store (.objectStore txn "meta")
|
||||
req (.put store #js {:key key :value value})]
|
||||
(set! (.-onsuccess req) (fn [_] (put! ch true) (close! ch)))
|
||||
(set! (.-onerror req) (fn [_] (close! ch)))
|
||||
ch))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Close
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn close!
|
||||
"Close the IDB connection."
|
||||
[db]
|
||||
(when db (.close db)))
|
||||
185
src/pocketbook/server.clj
Normal file
185
src/pocketbook/server.clj
Normal file
@@ -0,0 +1,185 @@
|
||||
(ns pocketbook.server
|
||||
"Pocketbook sync server. Single-file HTTP server backed by SQLite.
|
||||
|
||||
Endpoints:
|
||||
GET /sync?since=T&group=G — pull changes since timestamp
|
||||
POST /sync — push local changes (with version checks)
|
||||
|
||||
Start:
|
||||
clj -M:server
|
||||
bb -m pocketbook.server"
|
||||
(:require [org.httpkit.server :as http]
|
||||
[pocketbook.db :as db]
|
||||
[pocketbook.transit :as t]
|
||||
[clojure.string :as str])
|
||||
(:gen-class))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Config
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def default-config
|
||||
{:port 8090
|
||||
:db-path "pocketbook.db"
|
||||
:users nil ;; nil = no auth, or {"alice" {:token "abc" :groups #{"todo"}}}
|
||||
:cors true})
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auth
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- authenticate
|
||||
"Check Authorization header against config. Returns user map or nil."
|
||||
[config req]
|
||||
(if-let [users (:users config)]
|
||||
(let [header (get-in req [:headers "authorization"] "")
|
||||
token (str/replace header #"^Bearer\s+" "")]
|
||||
(some (fn [[username user]]
|
||||
(when (= token (:token user))
|
||||
(assoc user :username username)))
|
||||
users))
|
||||
;; No auth configured — allow all
|
||||
{:username "anonymous" :groups nil}))
|
||||
|
||||
(defn- authorized-group?
|
||||
"Check if user has access to a specific group."
|
||||
[user group]
|
||||
(or (nil? (:groups user)) ;; nil = access to all groups
|
||||
(contains? (:groups user) group)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Handlers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- transit-response [status body]
|
||||
{:status status
|
||||
:headers {"Content-Type" "application/transit+json"
|
||||
"Cache-Control" "no-cache"}
|
||||
:body (t/encode body)})
|
||||
|
||||
(defn- cors-headers [resp]
|
||||
(update resp :headers merge
|
||||
{"Access-Control-Allow-Origin" "*"
|
||||
"Access-Control-Allow-Methods" "GET, POST, OPTIONS"
|
||||
"Access-Control-Allow-Headers" "Content-Type, Authorization"
|
||||
"Access-Control-Max-Age" "86400"}))
|
||||
|
||||
(defn- handle-pull
|
||||
"GET /sync?since=T&group=G — return all docs updated since T in group G."
|
||||
[ds user req]
|
||||
(let [params (or (:query-params req) (:params req) {})
|
||||
group (get params "group" (get params :group))
|
||||
since (parse-long (or (get params "since" (get params :since)) "0"))]
|
||||
(if-not group
|
||||
(transit-response 400 {:error "Missing 'group' parameter"})
|
||||
(if-not (authorized-group? user group)
|
||||
(transit-response 403 {:error "Access denied to group"})
|
||||
(let [docs (db/docs-since ds group since)]
|
||||
(transit-response 200 docs))))))
|
||||
|
||||
(defn- handle-push
|
||||
"POST /sync — accept a batch of document writes.
|
||||
Body: [{:id ... :value ... :base-version N} ...]
|
||||
Entries with :deleted true are treated as deletes."
|
||||
[ds user req]
|
||||
(let [body (t/decode (:body req))
|
||||
docs (if (map? body) [body] body)
|
||||
;; Check all docs belong to authorized groups
|
||||
groups (into #{} (map #(first (str/split (:id %) #":" 2))) docs)
|
||||
denied (remove #(authorized-group? user %) groups)]
|
||||
(if (seq denied)
|
||||
(transit-response 403 {:error (str "Access denied to groups: " (str/join ", " denied))})
|
||||
(let [results (mapv (fn [doc]
|
||||
(if (:deleted doc)
|
||||
(db/delete! ds {:id (:id doc)
|
||||
:base-version (:base-version doc 0)})
|
||||
(db/upsert! ds {:id (:id doc)
|
||||
:value (:value doc)
|
||||
:base-version (:base-version doc 0)})))
|
||||
docs)]
|
||||
(transit-response 200 results)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Ring handler
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- parse-query-params [query-string]
|
||||
(when query-string
|
||||
(into {}
|
||||
(for [pair (str/split query-string #"&")
|
||||
:let [[k v] (str/split pair #"=" 2)]
|
||||
:when k]
|
||||
[k (or v "")]))))
|
||||
|
||||
(defn make-handler
|
||||
"Create the Ring handler function."
|
||||
[ds config]
|
||||
(fn [req]
|
||||
(let [req (assoc req :query-params (parse-query-params (:query-string req)))
|
||||
resp (cond
|
||||
;; CORS preflight
|
||||
(= :options (:request-method req))
|
||||
{:status 204 :headers {} :body nil}
|
||||
|
||||
;; Health check
|
||||
(= "/" (:uri req))
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "pocketbook ok"}
|
||||
|
||||
;; Sync endpoints
|
||||
(= "/sync" (:uri req))
|
||||
(let [user (authenticate config req)]
|
||||
(if-not user
|
||||
(transit-response 401 {:error "Unauthorized"})
|
||||
(case (:request-method req)
|
||||
:get (handle-pull ds user req)
|
||||
:post (handle-push ds user req)
|
||||
(transit-response 405 {:error "Method not allowed"}))))
|
||||
|
||||
:else
|
||||
{:status 404
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Not found"})]
|
||||
(if (:cors config)
|
||||
(cors-headers resp)
|
||||
resp))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Server lifecycle
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn start!
|
||||
"Start the Pocketbook server. Returns a stop function."
|
||||
([]
|
||||
(start! {}))
|
||||
([config]
|
||||
(let [config (merge default-config config)
|
||||
ds (db/open (:db-path config))
|
||||
handler (make-handler ds config)
|
||||
server (http/run-server handler {:port (:port config)})]
|
||||
(println (str "🔶 Pocketbook server running on http://localhost:" (:port config)))
|
||||
(println (str " Database: " (:db-path config)))
|
||||
(println (str " Auth: " (if (:users config) "enabled" "disabled")))
|
||||
{:stop server
|
||||
:ds ds
|
||||
:config config})))
|
||||
|
||||
(defn stop!
|
||||
"Stop a running server."
|
||||
[{:keys [stop]}]
|
||||
(when stop (stop)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Main
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn -main [& args]
|
||||
(let [port (some-> (first args) parse-long)
|
||||
db-path (second args)
|
||||
config (cond-> {}
|
||||
port (assoc :port port)
|
||||
db-path (assoc :db-path db-path))]
|
||||
(start! config)
|
||||
;; Keep the server running
|
||||
@(promise)))
|
||||
111
src/pocketbook/sync.cljs
Normal file
111
src/pocketbook/sync.cljs
Normal file
@@ -0,0 +1,111 @@
|
||||
(ns pocketbook.sync
|
||||
"HTTP sync client — pull and push documents to/from the Pocketbook server."
|
||||
(:require [cognitect.transit :as t]
|
||||
[cljs.core.async :refer [chan put! close!]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Transit over HTTP
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private writer (t/writer :json))
|
||||
(def ^:private reader (t/reader :json))
|
||||
|
||||
(defn- encode [v]
|
||||
(t/write writer v))
|
||||
|
||||
(defn- decode [s]
|
||||
(when (and s (not= s ""))
|
||||
(t/read reader s)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTTP helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- fetch-transit
|
||||
"Make an HTTP request with Transit encoding. Returns a channel
|
||||
yielding {:ok true :body <decoded>} or {:ok false :status N :error str}."
|
||||
[{:keys [url method body headers]}]
|
||||
(let [ch (chan 1)
|
||||
opts (clj->js
|
||||
(cond-> {:method (or method "GET")
|
||||
:headers (merge {"Content-Type" "application/transit+json"
|
||||
"Accept" "application/transit+json"}
|
||||
headers)}
|
||||
body (assoc :body (encode body))))]
|
||||
(-> (js/fetch url opts)
|
||||
(.then (fn [resp]
|
||||
(-> (.text resp)
|
||||
(.then (fn [text]
|
||||
(if (.-ok resp)
|
||||
(put! ch {:ok true :body (decode text)})
|
||||
(put! ch {:ok false
|
||||
:status (.-status resp)
|
||||
:error text}))
|
||||
(close! ch))))))
|
||||
(.catch (fn [err]
|
||||
(put! ch {:ok false :status 0 :error (str err)})
|
||||
(close! ch))))
|
||||
ch))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Pull
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn pull!
|
||||
"Pull documents from server updated since `since` for `group`.
|
||||
Returns a channel yielding {:ok true :docs [...]} or {:ok false :error str}."
|
||||
[{:keys [server token]} group since]
|
||||
(let [ch (chan 1)
|
||||
url (str server "?group=" (js/encodeURIComponent group)
|
||||
"&since=" since)]
|
||||
(cljs.core.async/go
|
||||
(let [result (cljs.core.async/<! (fetch-transit
|
||||
{:url url
|
||||
:method "GET"
|
||||
:headers (when token {"Authorization" (str "Bearer " token)})}))]
|
||||
(if (:ok result)
|
||||
(put! ch {:ok true :docs (:body result)})
|
||||
(put! ch result))
|
||||
(close! ch)))
|
||||
ch))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Push
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn push!
|
||||
"Push a batch of documents to the server.
|
||||
Each doc: {:id str :value any :base-version int} or {:id str :deleted true :base-version int}.
|
||||
Returns a channel yielding {:ok true :results [...]} or {:ok false :error str}."
|
||||
[{:keys [server token]} docs]
|
||||
(let [ch (chan 1)]
|
||||
(cljs.core.async/go
|
||||
(let [result (cljs.core.async/<! (fetch-transit
|
||||
{:url server
|
||||
:method "POST"
|
||||
:body docs
|
||||
:headers (when token {"Authorization" (str "Bearer " token)})}))]
|
||||
(if (:ok result)
|
||||
(put! ch {:ok true :results (:body result)})
|
||||
(put! ch result))
|
||||
(close! ch)))
|
||||
ch))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Online detection
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn online? []
|
||||
(.-onLine js/navigator))
|
||||
|
||||
(defn on-connectivity-change
|
||||
"Register callbacks for online/offline events. Returns a cleanup fn."
|
||||
[on-online on-offline]
|
||||
(let [online-handler (fn [_] (on-online))
|
||||
offline-handler (fn [_] (on-offline))]
|
||||
(.addEventListener js/window "online" online-handler)
|
||||
(.addEventListener js/window "offline" offline-handler)
|
||||
;; Return cleanup function
|
||||
(fn []
|
||||
(.removeEventListener js/window "online" online-handler)
|
||||
(.removeEventListener js/window "offline" offline-handler))))
|
||||
32
src/pocketbook/transit.clj
Normal file
32
src/pocketbook/transit.clj
Normal file
@@ -0,0 +1,32 @@
|
||||
(ns pocketbook.transit
|
||||
"Transit encoding/decoding helpers for the HTTP wire format."
|
||||
(:require [cognitect.transit :as t])
|
||||
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]))
|
||||
|
||||
(defn encode
|
||||
"Encode a Clojure value to a Transit+JSON byte array."
|
||||
[v]
|
||||
(let [out (ByteArrayOutputStream. 4096)
|
||||
w (t/writer out :json)]
|
||||
(t/write w v)
|
||||
(.toByteArray out)))
|
||||
|
||||
(defn encode-str
|
||||
"Encode a Clojure value to a Transit+JSON string."
|
||||
[v]
|
||||
(let [out (ByteArrayOutputStream. 4096)
|
||||
w (t/writer out :json)]
|
||||
(t/write w v)
|
||||
(.toString out "UTF-8")))
|
||||
|
||||
(defn decode
|
||||
"Decode a Transit+JSON byte array or input stream to a Clojure value."
|
||||
[input]
|
||||
(let [in (cond
|
||||
(instance? ByteArrayInputStream input) input
|
||||
(instance? java.io.InputStream input) input
|
||||
(bytes? input) (ByteArrayInputStream. input)
|
||||
(string? input) (ByteArrayInputStream. (.getBytes ^String input "UTF-8"))
|
||||
:else (throw (ex-info "Cannot decode, unsupported input type"
|
||||
{:type (type input)})))]
|
||||
(t/read (t/reader in :json))))
|
||||
88
test/pocketbook/auth_test.clj
Normal file
88
test/pocketbook/auth_test.clj
Normal 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
141
test/pocketbook/db_test.clj
Normal 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"))))
|
||||
143
test/pocketbook/server_test.clj
Normal file
143
test/pocketbook/server_test.clj
Normal 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))))))
|
||||
37
test/pocketbook/transit_test.clj
Normal file
37
test/pocketbook/transit_test.clj
Normal 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)))))
|
||||
Reference in New Issue
Block a user