Files
atomsync/src/pocketbook/idb.cljs
Florian Schroedl 6f70fbfdbb fix: resolve IDB nil-on-channel and CLJS deref bugs
- 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).
2026-04-04 16:53:54 +02:00

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)))