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:
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)))
|
||||
Reference in New Issue
Block a user