chat / client / src / sueta / app / migrate_storage.cljs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
;; ported-from: src/app/migrate-storage.ts
;;
;; One-time migration of persistent storage from the historical hardcoded
;; "sueta" namespace to the derived one (config.cljs NS). Runs at boot before
;; anything opens the new-NS stores; a no-op for fresh installs, forks, and once
;; the marker is set.
;;
;; Everything is COPIED, never deleted — the old keys stay behind as a rollback
;; safety net (they're small; the blockstore copy is the only big one, and a
;; future release can garbage-collect the originals).
;;
;; What moves:
;; - localStorage: id, name, notify, trust, rooms, migrated:* → "<NS>:…"
;; - IndexedDB: sueta-blocks / sueta-data (Helia stores; DB name doubles as the
;;   object-store name in blockstore-idb/datastore-idb, so the copy renames both)
;;   → "<NS>-blocks" / "<NS>-data"
;; Not moved (correct by design):
;; - "sueta-history": literal v1 database, read in place by app/history.cljs
;; - OrbitDB per-room directories: keyed by salt-derived roomId, fork-unique
(ns sueta.app.migrate-storage
  (:require [sueta.config :as config]
            [ardegazu.rooms.js :as j]
            [shadow.cljs.modern :refer (js-await)]))

(def ^:private LEGACY-NS "sueta")
(def ^:private MARKER (config/ns-key "storage-migrated"))
(def ^:private LS-KEYS #js ["id" "name" "notify" "trust" "rooms"])

(declare migrate-local-storage copy-idb open-existing copy-store)

(defn migrate-storage-ns
  ([] (migrate-storage-ns (fn [_] js/undefined)))
  ([on-warn]
   (-> (js/Promise.resolve nil)
       (.then
        (fn [_]
          (if (identical? config/NS LEGACY-NS)
            js/undefined
            (let [have-storage (try
                                 (if (j/truthy? (js/localStorage.getItem MARKER)) :done :usable)
                                 (catch :default _ :none))]
              (cond
                ;; no storage at all — nothing to migrate to or from
                (identical? :none have-storage) js/undefined
                (identical? :done have-storage) js/undefined
                :else
                (let [has-legacy (or (some? (js/localStorage.getItem (str LEGACY-NS ":rooms")))
                                     (some? (js/localStorage.getItem (str LEGACY-NS ":id"))))]
                  (if-not has-legacy
                    (do (js/localStorage.setItem MARKER "fresh") js/undefined)
                    (-> (js/Promise.resolve nil)
                        (.then (fn [_]
                                 (migrate-local-storage)
                                 (js-await [_ (copy-idb (str LEGACY-NS "-blocks") (config/ns-db "blocks"))]
                                   (js-await [_ (copy-idb (str LEGACY-NS "-data") (config/ns-db "data"))]
                                     (do (js/localStorage.setItem MARKER (str (js/Date.now)))
                                         js/undefined)))))
                        (.catch
                         (fn [err]
                           ;; marker stays unset ⇒ retried next boot; the app
                           ;; still works, at worst with history invisible until
                           ;; the copy succeeds or peers re-sync it
                           (js/console.warn "storage namespace migration failed, will retry" err)
                           (on-warn "storage migration incomplete — old data may be missing until the next reload")
                           js/undefined)))))))))))))

(defn- migrate-local-storage []
  ;; snapshot the key list first: setItem while iterating localStorage.key(i)
  ;; would shift the indices under us
  (let [all-keys (array)]
    (dotimes [i (.-length js/localStorage)]
      (let [k (js/localStorage.key i)]
        (when (j/truthy? k) (.push all-keys k))))
    (doseq [k (array-seq all-keys)]
      (let [tail (.slice ^string k (inc (.-length LEGACY-NS)))
            is-flat (and (.includes LS-KEYS tail) (.startsWith ^string k (str LEGACY-NS ":")))
            is-migrated-flag (.startsWith ^string k (str LEGACY-NS ":migrated:"))]
        (when (or is-flat is-migrated-flag)
          ;; an existing target is never overwritten
          (let [target (str config/NS ":" tail)]
            (when (nil? (js/localStorage.getItem target))
              (let [v (js/localStorage.getItem k)]
                (when (some? v) (js/localStorage.setItem target v)))))))))
  js/undefined)

(defn- copy-idb
  "Copy one IndexedDB database. blockstore-idb / datastore-idb name their single
   object store after the DB itself, so a store named like the SOURCE db is
   recreated under the TARGET name; any other store keeps its name."
  [src-name dst-name]
  (js-await [src (open-existing src-name)]
    (if-not (j/truthy? src)
      js/undefined ; never created (e.g. user never opened a room) — fine
      (-> (js/Promise.resolve nil)
          (.then
           (fn [_]
             (let [store-names (js/Array.from (unchecked-get src "objectStoreNames"))]
               (if (zero? (.-length store-names))
                 js/undefined
                 (js-await [dst (js/Promise.
                                 (fn [resolve reject]
                                   (let [^js req (js/indexedDB.open dst-name 1)]
                                     (set! (.-onupgradeneeded req)
                                           (fn []
                                             (doseq [nm (array-seq store-names)]
                                               (let [src-meta (-> ^js src (.transaction nm) (.objectStore nm))
                                                     target-name (if (identical? nm src-name) dst-name nm)
                                                     result ^js (.-result req)]
                                                 (when-not (.contains (.-objectStoreNames result) target-name)
                                                   (.createObjectStore
                                                    result target-name
                                                    (let [o (j/ordered)
                                                          kp (.-keyPath src-meta)]
                                                      ;; TS `keyPath: meta.keyPath ?? undefined`
                                                      (unchecked-set o "keyPath"
                                                                     (if (nil? kp) js/undefined kp))
                                                      (unchecked-set o "autoIncrement" (.-autoIncrement src-meta))
                                                      o)))))))
                                     (set! (.-onsuccess req) (fn [] (resolve (.-result req))))
                                     (set! (.-onerror req) (fn [] (reject (.-error req)))))))]
                   (-> ((fn step [i]
                          (if (>= i (.-length store-names))
                            (js/Promise.resolve nil)
                            (let [nm (aget store-names i)]
                              (js-await [_ (copy-store src dst nm (if (identical? nm src-name) dst-name nm))]
                                (step (inc i))))))
                        0)
                       (.finally (fn [] (.close ^js dst)))))))))
          (.finally (fn [] (.close ^js src)))))))

(defn- open-existing
  "Open without creating: probe indexedDB.databases() when available."
  [db-name]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (js-await [absent (if (identical? "function" (js* "typeof indexedDB.databases"))
                             (js-await [all (js/indexedDB.databases)]
                               (not (.some ^js all (fn [d] (identical? (.-name ^js d) db-name)))))
                             (js/Promise.resolve false))]
           (if absent
             nil
             (js/Promise.
              (fn [resolve reject]
                (let [^js req (js/indexedDB.open db-name)
                      existed (volatile! true)]
                  (set! (.-onupgradeneeded req) (fn [] (vreset! existed false))) ; was just created empty
                  (set! (.-onsuccess req)
                        (fn []
                          (if @existed
                            (resolve (.-result req))
                            (do (.close ^js (.-result req)) (resolve nil)))))
                  (set! (.-onerror req) (fn [] (reject (.-error req)))))))))))))

(def ^:private COPY-BATCH 250)

(defn- copy-store
  "Batched cursor copy — the blockstore can hold many MB of image blocks."
  [src dst src-store dst-store]
  (let [last-key (volatile! js/undefined)]
    ((fn step []
       (js-await [batch (js/Promise.
                         (fn [resolve reject]
                           (let [rows (array)
                                 key-range (if (identical? @last-key js/undefined)
                                             js/undefined
                                             (js/IDBKeyRange.lowerBound @last-key true))
                                 ^js req (-> ^js src (.transaction src-store) (.objectStore src-store)
                                             (.openCursor key-range))]
                             (set! (.-onsuccess req)
                                   (fn []
                                     (let [cursor (.-result req)]
                                       (if (and (j/truthy? cursor) (< (.-length rows) COPY-BATCH))
                                         (do (.push rows (j/ordered "key" (.-key ^js cursor)
                                                                    "value" (.-value ^js cursor)))
                                             (.continue ^js cursor))
                                         (resolve rows)))))
                             (set! (.-onerror req) (fn [] (reject (.-error req)))))))]
         (if (zero? (.-length batch))
           js/undefined
           (do
             (vreset! last-key (unchecked-get (aget batch (dec (.-length batch))) "key"))
             (js-await [_ (js/Promise.
                           (fn [resolve reject]
                             (let [^js tx (.transaction ^js dst dst-store "readwrite")
                                   write (.objectStore tx dst-store)]
                               (doseq [row (array-seq batch)]
                                 (if (nil? (.-keyPath ^js write))
                                   (.put ^js write (unchecked-get row "value") (unchecked-get row "key"))
                                   (.put ^js write (unchecked-get row "value"))))
                               (set! (.-oncomplete tx) (fn [] (resolve js/undefined)))
                               (set! (.-onerror tx) (fn [] (reject (.-error tx)))))))]
               (if (< (.-length batch) COPY-BATCH)
                 js/undefined
                 (step))))))))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git