"use client"; /** * Chat conversations, kept in IndexedDB so history survives reloads and the * tab can be closed mid-conversation. The in-memory mirror makes reads * synchronous for React (`useSyncExternalStore`); writes update the mirror * immediately and persist asynchronously. * * Versioned DB `freezmo-chat` v1 with one store: * chats keyPath id (index: updatedAt) * * Every stored record is validated with the shared zod schema, and the store * is capped (oldest conversations first) so a long-lived browser can't grow * without bound. Chats are part of the export/backup and the encrypted sync * payload — never the API key. */ import { chatConversationSchema, type ChatConversation, type ChatMessage } from "./schemas"; import { markSyncDirty } from "./sync-state"; const DB_NAME = "freezmo-chat"; const DB_VERSION = 1; const CHATS = "chats"; /** Caps: a personal app, but still bounded. */ export const MAX_CONVERSATIONS = 100; export const MAX_MESSAGES_PER_CHAT = 300; const EMPTY: ChatConversation[] = []; type Listener = () => void; let conversations: ChatConversation[] = []; let cached: ChatConversation[] = EMPTY; const listeners = new Set(); let hydrated = false; let hydration: Promise | null = null; function notify(): void { cached = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt); listeners.forEach((listener) => listener()); } function openDb(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(CHATS)) { const store = db.createObjectStore(CHATS, { keyPath: "id" }); store.createIndex("updatedAt", "updatedAt"); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function withDb(_mode: IDBTransactionMode, run: (db: IDBDatabase) => T | Promise): Promise { const db = await openDb(); try { return await run(db); } finally { window.setTimeout(() => db.close(), 1000); } } function requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function persist(conversation: ChatConversation): void { void withDb("readwrite", (db) => { const transaction = db.transaction(CHATS, "readwrite"); transaction.objectStore(CHATS).put(conversation); }).catch((error) => console.warn("[freezmo] could not save the chat", error)); } function removeFromDb(id: string): void { void withDb("readwrite", (db) => { const transaction = db.transaction(CHATS, "readwrite"); transaction.objectStore(CHATS).delete(id); }).catch((error) => console.warn("[freezmo] could not delete the chat", error)); } /** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */ export function ensureChatsHydrated(): Promise { if (hydrated) return Promise.resolve(); if (hydration) return hydration; hydration = (async () => { if (typeof indexedDB === "undefined") { hydrated = true; return; } try { await withDb("readonly", async (db) => { const transaction = db.transaction(CHATS, "readonly"); const loaded = await requestToPromise(transaction.objectStore(CHATS).getAll() as IDBRequest); const known = new Set(conversations.map((entry) => entry.id)); const valid: ChatConversation[] = []; for (const raw of loaded) { const parsed = chatConversationSchema.safeParse(raw); if (!parsed.success) continue; if (known.has(parsed.data.id)) continue; valid.push(parsed.data); } conversations = [...conversations, ...valid] .sort((a, b) => b.updatedAt - a.updatedAt) .slice(0, MAX_CONVERSATIONS); }); } catch (error) { console.warn("[freezmo] could not read the chat history", error); } finally { hydrated = true; notify(); } })(); return hydration; } export const chatHistoryStore = { subscribe(listener: Listener): () => void { listeners.add(listener); void ensureChatsHydrated(); return () => listeners.delete(listener); }, getSnapshot(): ChatConversation[] { return cached; }, getServerSnapshot(): ChatConversation[] { return EMPTY; }, }; export function getConversation(id: string): ChatConversation | undefined { return conversations.find((entry) => entry.id === id); } export function conversationMessages(conversation: ChatConversation): ChatMessage[] { return conversation.messages.slice(-MAX_MESSAGES_PER_CHAT); } /** Creates or replaces one conversation and persists it. */ export function saveConversation(conversation: ChatConversation): void { const parsed = chatConversationSchema.safeParse({ ...conversation, messages: conversation.messages.slice(-MAX_MESSAGES_PER_CHAT), }); if (!parsed.success) { console.warn("[freezmo] skipping invalid chat", parsed.error.issues[0]); return; } const next = parsed.data; conversations = [next, ...conversations.filter((entry) => entry.id !== next.id)] .sort((a, b) => b.updatedAt - a.updatedAt) .slice(0, MAX_CONVERSATIONS); notify(); persist(next); markSyncDirty(); } export function renameConversation(id: string, title: string): void { const trimmed = title.trim().slice(0, 200); if (!trimmed) return; const current = getConversation(id); if (!current) return; saveConversation({ ...current, title: trimmed, updatedAt: Date.now() }); } export function deleteConversation(id: string): void { conversations = conversations.filter((entry) => entry.id !== id); notify(); removeFromDb(id); markSyncDirty(); } /** Used by backup import and sync: wholesale replacement (no dirty marking — callers that need it wrap with withoutDirtyTracking). */ export function replaceChats(next: ChatConversation[]): void { const valid: ChatConversation[] = []; for (const raw of next) { const parsed = chatConversationSchema.safeParse(raw); if (parsed.success) valid.push(parsed.data); } conversations = valid.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS); notify(); void withDb("readwrite", (db) => { const transaction = db.transaction(CHATS, "readwrite"); transaction.objectStore(CHATS).clear(); for (const conversation of conversations) transaction.objectStore(CHATS).put(conversation); }).catch((error) => console.warn("[freezmo] could not replace the chat history", error)); }