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]);
}
|