|
| 1 | +/** |
| 2 | + * Client-side repository for IndexedDB-backed persistence of conversations. |
| 3 | + * |
| 4 | + * Maintains two object stores: |
| 5 | + * - `conversations` — sidebar list (ConvSidebar items) |
| 6 | + * - `conversation_details` — full message history (Conversation-like payloads) |
| 7 | + * |
| 8 | + * Consistency model: **server always wins**. Data returned from the server |
| 9 | + * always overwrites local cache entries. Only fully-acknowledged messages |
| 10 | + * (confirmed by the server) are persisted; optimistic/transient state lives |
| 11 | + * exclusively in the UI stores. |
| 12 | + */ |
| 13 | + |
| 14 | +import { browser } from "$app/environment"; |
| 15 | +import type { ConvSidebar } from "$lib/types/ConvSidebar"; |
| 16 | + |
| 17 | +const DB_NAME = "chat-ui-cache"; |
| 18 | +const DB_VERSION = 1; |
| 19 | + |
| 20 | +/** Lightweight serialisable shape for the sidebar list (mirrors ConvSidebar). */ |
| 21 | +export interface StoredConversation { |
| 22 | + id: string; |
| 23 | + title: string; |
| 24 | + updatedAt: string; // ISO string (Dates serialised for structured clone safety) |
| 25 | + model?: string; |
| 26 | +} |
| 27 | + |
| 28 | +/** Lightweight serialisable shape for a full conversation payload. */ |
| 29 | +export interface StoredConversationDetail { |
| 30 | + id: string; |
| 31 | + title: string; |
| 32 | + model: string; |
| 33 | + updatedAt: string; // ISO string |
| 34 | + messages: string; // JSON-stringified Message[] (preserves Date etc. via superjson) |
| 35 | + preprompt?: string; |
| 36 | + rootMessageId?: string; |
| 37 | + shared: boolean; |
| 38 | + modelId: string; |
| 39 | +} |
| 40 | + |
| 41 | +function openDB(): Promise<IDBDatabase> { |
| 42 | + return new Promise((resolve, reject) => { |
| 43 | + const request = indexedDB.open(DB_NAME, DB_VERSION); |
| 44 | + request.onupgradeneeded = () => { |
| 45 | + const db = request.result; |
| 46 | + if (!db.objectStoreNames.contains("conversations")) { |
| 47 | + db.createObjectStore("conversations", { keyPath: "id" }); |
| 48 | + } |
| 49 | + if (!db.objectStoreNames.contains("conversation_details")) { |
| 50 | + db.createObjectStore("conversation_details", { keyPath: "id" }); |
| 51 | + } |
| 52 | + }; |
| 53 | + request.onsuccess = () => resolve(request.result); |
| 54 | + request.onerror = () => reject(request.error); |
| 55 | + }); |
| 56 | +} |
| 57 | + |
| 58 | +function getStore(db: IDBDatabase, storeName: string, mode: IDBTransactionMode): IDBObjectStore { |
| 59 | + const tx = db.transaction(storeName, mode); |
| 60 | + return tx.objectStore(storeName); |
| 61 | +} |
| 62 | + |
| 63 | +function promisifyRequest<T>(req: IDBRequest<T>): Promise<T> { |
| 64 | + return new Promise((resolve, reject) => { |
| 65 | + req.onsuccess = () => resolve(req.result); |
| 66 | + req.onerror = () => reject(req.error); |
| 67 | + }); |
| 68 | +} |
| 69 | + |
| 70 | +export class ConversationRepository { |
| 71 | + private dbPromise: Promise<IDBDatabase> | null = null; |
| 72 | + |
| 73 | + private async ensureDB(): Promise<IDBDatabase> { |
| 74 | + if (!this.dbPromise) { |
| 75 | + this.dbPromise = openDB(); |
| 76 | + } |
| 77 | + return this.dbPromise; |
| 78 | + } |
| 79 | + |
| 80 | + // --------------------------------------------------------------------------- |
| 81 | + // Conversations (sidebar list) |
| 82 | + // --------------------------------------------------------------------------- |
| 83 | + |
| 84 | + /** Replace the entire sidebar list in one transaction. */ |
| 85 | + async setConversations(items: ConvSidebar[]): Promise<void> { |
| 86 | + if (!browser) return; |
| 87 | + const db = await this.ensureDB(); |
| 88 | + const store = getStore(db, "conversations", "readwrite"); |
| 89 | + // Clear and re-insert for a full replace |
| 90 | + await promisifyRequest(store.clear()); |
| 91 | + for (const item of items) { |
| 92 | + store.put(this.toStoredConversation(item)); |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + /** Return all cached sidebar conversations, newest first. */ |
| 97 | + async getConversations(): Promise<ConvSidebar[]> { |
| 98 | + if (!browser) return []; |
| 99 | + const db = await this.ensureDB(); |
| 100 | + const store = getStore(db, "conversations", "readonly"); |
| 101 | + const all = await promisifyRequest(store.getAll()); |
| 102 | + return (all ?? []) |
| 103 | + .map((s) => this.fromStoredConversation(s)) |
| 104 | + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); |
| 105 | + } |
| 106 | + |
| 107 | + // --------------------------------------------------------------------------- |
| 108 | + // Conversation details (full message history) |
| 109 | + // --------------------------------------------------------------------------- |
| 110 | + |
| 111 | + /** Store (overwrite) a full conversation detail. */ |
| 112 | + async setConversationDetail( |
| 113 | + id: string, |
| 114 | + detail: Omit<StoredConversationDetail, "id"> |
| 115 | + ): Promise<void> { |
| 116 | + if (!browser) return; |
| 117 | + const db = await this.ensureDB(); |
| 118 | + const store = getStore(db, "conversation_details", "readwrite"); |
| 119 | + await promisifyRequest(store.put({ id, ...detail })); |
| 120 | + } |
| 121 | + |
| 122 | + /** Retrieve a full conversation detail by id. */ |
| 123 | + async getConversationDetail(id: string): Promise<StoredConversationDetail | undefined> { |
| 124 | + if (!browser) return undefined; |
| 125 | + const db = await this.ensureDB(); |
| 126 | + const store = getStore(db, "conversation_details", "readonly"); |
| 127 | + return promisifyRequest(store.get(id)); |
| 128 | + } |
| 129 | + |
| 130 | + /** Remove a conversation detail by id. */ |
| 131 | + async removeConversationDetail(id: string): Promise<void> { |
| 132 | + if (!browser) return; |
| 133 | + const db = await this.ensureDB(); |
| 134 | + const store = getStore(db, "conversation_details", "readwrite"); |
| 135 | + await promisifyRequest(store.delete(id)); |
| 136 | + } |
| 137 | + |
| 138 | + // --------------------------------------------------------------------------- |
| 139 | + // Bulk operations |
| 140 | + // --------------------------------------------------------------------------- |
| 141 | + |
| 142 | + /** Clear all cached data (called on logout). */ |
| 143 | + async clearAll(): Promise<void> { |
| 144 | + if (!browser) return; |
| 145 | + const db = await this.ensureDB(); |
| 146 | + const tx = db.transaction(["conversations", "conversation_details"], "readwrite"); |
| 147 | + tx.objectStore("conversations").clear(); |
| 148 | + tx.objectStore("conversation_details").clear(); |
| 149 | + return new Promise((resolve, reject) => { |
| 150 | + tx.oncomplete = () => resolve(); |
| 151 | + tx.onerror = () => reject(tx.error); |
| 152 | + }); |
| 153 | + } |
| 154 | + |
| 155 | + // --------------------------------------------------------------------------- |
| 156 | + // Serialisation helpers |
| 157 | + // --------------------------------------------------------------------------- |
| 158 | + |
| 159 | + private toStoredConversation(item: ConvSidebar): StoredConversation { |
| 160 | + return { |
| 161 | + id: String(item.id), |
| 162 | + title: item.title, |
| 163 | + updatedAt: item.updatedAt.toISOString(), |
| 164 | + model: item.model, |
| 165 | + }; |
| 166 | + } |
| 167 | + |
| 168 | + private fromStoredConversation(s: StoredConversation): ConvSidebar { |
| 169 | + return { |
| 170 | + id: s.id, |
| 171 | + title: s.title, |
| 172 | + updatedAt: new Date(s.updatedAt), |
| 173 | + model: s.model, |
| 174 | + }; |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +/** Singleton instance — the single source of truth for IndexedDB access. */ |
| 179 | +export const conversationRepository = new ConversationRepository(); |
0 commit comments