File size: 3,335 Bytes
e6ed1f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"use client";

/**
 * Drives hydration of the chat store from the backend on mount and
 * pushes changes back up via debounced PUT.
 *
 * Behavior:
 *   - On first mount: GET /conversations β†’ merge into store β†’ mark hydrated.
 *   - On any change after hydration: schedule a PUT (debounced 1500ms).
 *   - In-flight saves are aborted by the next save to avoid stale writes.
 *   - Pending turns are excluded from the saved snapshot β€” the assistant
 *     response lands as a normal change after pending=false flips.
 */
import { useEffect, useRef } from "react";
import { useChatStore } from "./chatStore";
import { loadConversations, saveConversations } from "./sync";

const SAVE_DEBOUNCE_MS = 1500;

export function useBackendSync() {
  const hydrated = useChatStore((s) => s.hydrated);
  const conversations = useChatStore((s) => s.conversations);
  const activeId = useChatStore((s) => s.activeId);

  const initialFetchStarted = useRef(false);
  const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const inFlight = useRef<AbortController | null>(null);

  // ── 1) Hydrate on mount ───────────────────────────────────────────
  useEffect(() => {
    if (initialFetchStarted.current) return;
    initialFetchStarted.current = true;

    let cancelled = false;
    (async () => {
      const remote = await loadConversations();
      if (cancelled) return;
      const store = useChatStore.getState();
      if (remote && remote.conversations) {
        store._hydrateFromRemote({
          conversations: remote.conversations,
          activeId: remote.activeId ?? null,
        });
      } else {
        // No remote / fetch failed β€” proceed with whatever localStorage had.
        store._setHydrated(true);
      }
    })();

    return () => {
      cancelled = true;
    };
  }, []);

  // ── 2) Debounced save on change (only after hydrated) ─────────────
  useEffect(() => {
    if (!hydrated) return;

    if (saveTimer.current) clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(() => {
      // Abort any in-flight save before starting a new one
      if (inFlight.current) inFlight.current.abort();
      const ctrl = new AbortController();
      inFlight.current = ctrl;

      // Strip in-flight pending turns from the saved snapshot β€” those
      // resolve when the request returns and trigger their own save.
      const sanitized: Record<string, typeof conversations[string]> = {};
      for (const [id, conv] of Object.entries(conversations)) {
        sanitized[id] = {
          ...conv,
          turns: conv.turns.filter((t) => !t.pending),
        };
      }

      const setStatus = useChatStore.getState()._setSyncStatus;
      setStatus("saving");
      saveConversations(
        {
          conversations: sanitized,
          activeId,
        },
        ctrl.signal
      ).then((ok) => {
        if (ctrl.signal.aborted) return;
        setStatus(ok ? "idle" : "error");
        if (ok) {
          useChatStore.setState({ lastSavedAt: Date.now() });
        }
      });
    }, SAVE_DEBOUNCE_MS);

    return () => {
      if (saveTimer.current) clearTimeout(saveTimer.current);
    };
  }, [hydrated, conversations, activeId]);
}