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