d2l-ui / lib /useBackendSync.ts
Berkkirik's picture
feat: persist chat history to backend (debounced PUT, hydrate on mount)
e6ed1f4
"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]);
}