- idb/get-meta and idb/get-doc were calling (put! ch nil) when no result found, which is illegal in core.async. Now close the channel instead (reader gets nil from closed channel). - todomvc.cljs used @(!todos) which calls the atom as a function. Fixed to @@!todos (deref atom → deref SyncedAtom).
226 lines
8.0 KiB
Clojure
226 lines
8.0 KiB
Clojure
(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 :as async :refer [chan put!]]))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; 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)))
|
|
(async/close! ch)))
|
|
(set! (.-onerror req)
|
|
(fn [e]
|
|
(js/console.error "IDB open error:" e)
|
|
(async/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) (async/close! ch)))
|
|
(set! (.-onerror req) (fn [e] (js/console.error "IDB put error:" e) (async/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) (async/close! ch)))
|
|
(set! (.-onerror txn) (fn [e] (js/console.error "IDB batch put error:" e) (async/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))]
|
|
(if result
|
|
(do (put! ch {:id (.-id result)
|
|
:value (decode (.-value result))
|
|
:version (.-version result)
|
|
:updated (.-updated result)
|
|
:deleted (.-deleted result)
|
|
:synced (.-synced result)})
|
|
(async/close! ch))
|
|
(async/close! ch)))))
|
|
(set! (.-onerror req)
|
|
(fn [e] (js/console.error "IDB get error:" e) (async/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)
|
|
(async/close! ch))))))
|
|
(set! (.-onerror req)
|
|
(fn [e] (js/console.error "IDB cursor error:" e) (async/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)
|
|
(async/close! ch))))))
|
|
(set! (.-onerror req)
|
|
(fn [e] (js/console.error "IDB unsynced error:" e) (async/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) (async/close! ch)))
|
|
(set! (.-onerror req) (fn [e] (js/console.error "IDB delete error:" e) (async/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))]
|
|
(if result
|
|
(do (put! ch (.-value result))
|
|
(async/close! ch))
|
|
(async/close! ch)))))
|
|
(set! (.-onerror req) (fn [_] (async/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) (async/close! ch)))
|
|
(set! (.-onerror req) (fn [_] (async/close! ch)))
|
|
ch))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Close
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defn close-db!
|
|
"Close the IDB connection."
|
|
[db]
|
|
(when db (.close db)))
|