(ns outliner.boundaries.storage (:require [clojure.edn :as edn] [outliner.boundaries.firebase :as fb] [outliner.boundaries.firestore :as firestore] [outliner.model.core.logic :as logic] [outliner.control.system :as system])) ;; --- Synchronization Strategy --- ;; ;; The application uses a local-first, asynchronous synchronization strategy: ;; ;; 1. Local Persistence: ;; Every state change is immediately saved to localStorage. This ensures ;; that the user's work is preserved even if they are offline or the ;; browser crashes. ;; ;; 2. Cloud Persistence (Firebase): ;; - When a user is signed in, changes are synced to Firestore. ;; - Sync is debounced (1000ms) to avoid hitting Firestore rate limits ;; during active typing. ;; - Only the nodes that changed (upsert/delete) are sent to the cloud, ;; plus the 'main' document containing the tree structure (root IDs). ;; ;; 3. Optimistic Updates & Reconciliation: ;; - The UI updates immediately (optimistic). ;; - 'last-seen-doc' tracks the state that has been confirmed by the server. ;; - Remote changes are received via 'listen-to-doc' and merged into the ;; local state. ;; - Snapshots with 'hasPendingWrites' are ignored to prevent the local ;; state from flickering back to the server's version while waiting ;; for a local write to be confirmed. (defonce _clean-storage (when-not (.getItem js/localStorage "storage-cleaned-v2") (.removeItem js/localStorage "outliner-state") (.setItem js/localStorage "storage-cleaned-v2" "true") (js/console.log "Cleared corrupted local storage"))) (def storage-key "outliner-state") ;; Sync state atoms (defonce debounce-timer (atom nil)) (defonce last-seen-doc (atom nil)) (defn- strip-metadata [doc] (dissoc doc :metadata)) (defn- strip-for-save [doc] (-> doc strip-metadata system/strip-system-nodes)) ;; Local storage functions (defn sanitize-state [state] (some-> state :doc system/strip-system-nodes)) (defn save-local! [state] (let [safe-state (sanitize-state state)] (when safe-state (.setItem js/localStorage storage-key (pr-str safe-state))))) (defn load-local [user-id] (when-let [saved (.getItem js/localStorage storage-key)] (try (-> saved edn/read-string firestore/keywordize-doc (system/inject-system-nodes user-id)) (catch js/Error e (js/console.error "Failed to load local state" e))))) (defn clear-local! [] (.removeItem js/localStorage storage-key)) ;; Storage orchestration (defn save-cloud-doc! [user-id doc-data old-doc-data & [on-success on-error]] (firestore/save-doc! user-id doc-data old-doc-data on-success on-error)) (defn on-sync-success [sys-state-atom doc] (reset! last-seen-doc doc) (swap! sys-state-atom (fn [s] (if (= (:sync-status s) :syncing) (assoc s :sync-status :synced :last-sync-timestamp (.getTime (js/Date.))) s)))) (defn on-sync-error [sys-state-atom _e] (swap! sys-state-atom assoc :sync-status :error)) (defn perform-cloud-sync! [user-id doc sys-state-atom] (let [old-doc @last-seen-doc timestamp (.getTime (js/Date.)) stripped-doc (system/strip-system-nodes doc) doc-with-meta (assoc stripped-doc :metadata {:lastModified timestamp :version 1 :clientVersion "1.0.0"})] (swap! sys-state-atom assoc :sync-status :syncing) (firestore/save-doc! user-id doc-with-meta old-doc #(on-sync-success sys-state-atom doc-with-meta) #(on-sync-error sys-state-atom %)))) (defn schedule-cloud-sync! [user doc-state-atom sys-state-atom] (when (not= (:sync-status @sys-state-atom) :loading) (let [doc @doc-state-atom] (when (not= (strip-for-save doc) (strip-for-save @last-seen-doc)) (do (when @debounce-timer (js/clearTimeout @debounce-timer)) (swap! sys-state-atom assoc :sync-status :pending-sync) (reset! debounce-timer (js/setTimeout #(perform-cloud-sync! (fb/get-user-uid user) @doc-state-atom sys-state-atom) 1000))))))) (defn save-state! [doc-state-atom sys-state-atom user] (save-local! {:doc @doc-state-atom}) (if user (schedule-cloud-sync! user doc-state-atom sys-state-atom) (swap! sys-state-atom assoc :sync-status :synced))) (defn upload-initial-local-state! [user local-state callback] (let [stripped (system/strip-system-nodes local-state)] (firestore/save-doc! (fb/get-user-uid user) stripped nil (fn [] (reset! last-seen-doc stripped))) (callback local-state))) (defn process-cloud-load [cloud-state user-id callback] (reset! last-seen-doc cloud-state) (save-local! {:doc cloud-state}) (callback (system/inject-system-nodes cloud-state user-id))) (defn load-state [user callback] (let [user-id (fb/get-user-uid user)] (if user (firestore/load-doc user-id (fn [cloud-state] (let [local-state (load-local user-id)] (cond cloud-state (process-cloud-load cloud-state user-id callback) local-state (upload-initial-local-state! user local-state callback) :else (callback nil))))) (callback (load-local nil))))) (defn process-cloud-update [new-doc doc-state-atom sys-state-atom] (let [new-ts (get-in new-doc [:metadata :lastModified] 0) old-ts (get-in @last-seen-doc [:metadata :lastModified] 0) user-id (fb/get-user-uid (:user @sys-state-atom))] (if (and (logic/valid-doc? new-doc) (>= new-ts old-ts) (not (#{:pending-sync :syncing} (:sync-status @sys-state-atom)))) (try (when (not= (strip-metadata new-doc) (strip-for-save @doc-state-atom)) (let [doc-with-system (system/inject-system-nodes new-doc user-id)] (reset! last-seen-doc new-doc) (reset! doc-state-atom doc-with-system) (js/console.log "State updated from cloud"))) (catch js/Error e (js/console.error "Error processing cloud update:" e) (js/console.error "New doc data:" (clj->js new-doc)))) (js/console.warn "Received invalid state from cloud, ignoring update.")))) (defn on-firestore-event [event doc-state-atom sys-state-atom] (case (:type event) :updated (process-cloud-update (:data event) doc-state-atom sys-state-atom) :deleted (js/console.warn "Cloud document was deleted") :error (js/console.error "Sync error:" (:error event)))) (defn setup-sync [user doc-state-atom sys-state-atom] (when user (let [user-id (fb/get-user-uid user) listener (firestore/listen-to-doc user-id #(on-firestore-event % doc-state-atom sys-state-atom))] (swap! sys-state-atom assoc :firestore-listener listener) listener))) (defn cleanup-sync [sys-state-atom] (when @debounce-timer (js/clearTimeout @debounce-timer) (reset! debounce-timer nil)) (reset! last-seen-doc nil) (when-let [listener (:firestore-listener @sys-state-atom)] (listener) (swap! sys-state-atom dissoc :firestore-listener)))