Skip to content

Commit bd3d5ff

Browse files
cto-new[bot]LoneRifle
authored andcommitted
feat: implement IndexedDB repository layer for conversation persistence
1 parent 448892f commit bd3d5ff

4 files changed

Lines changed: 262 additions & 3 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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();

src/lib/stores/conversations.svelte.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { browser } from "$app/environment";
2424
import { getContext, setContext } from "svelte";
2525
import type { ConvSidebar } from "$lib/types/ConvSidebar";
2626
import { useAPIClient, handleResponse } from "$lib/APIClient";
27+
import { conversationRepository } from "$lib/repositories/ConversationRepository";
2728

2829
export const CONVERSATIONS_CONTEXT_KEY = "conversationsStore";
2930

@@ -44,6 +45,8 @@ class ConversationsStore {
4445
/** Replace the entire list (called from layout when data.conversations changes). */
4546
init(conversations: ConvSidebar[]): void {
4647
this.#list = conversations;
48+
// Persist the server-confirmed list to IndexedDB for offline fallback.
49+
void conversationRepository.setConversations(conversations);
4750
}
4851

4952
/**
@@ -88,11 +91,21 @@ class ConversationsStore {
8891
updatedAt: new Date(conv.updatedAt),
8992
}));
9093
this.#list = freshList;
94+
// Persist the server-confirmed list to IndexedDB.
95+
void conversationRepository.setConversations(freshList);
9196
} catch (err) {
9297
// Non-fatal: keep the existing list rather than blanking the sidebar.
9398
console.error("[conversationsStore] refresh failed", err);
9499
}
95100
}
101+
102+
/** Clear all locally cached data (called on user logout). */
103+
async clearCache(): Promise<void> {
104+
this.#list = [];
105+
if (browser) {
106+
await conversationRepository.clearAll();
107+
}
108+
}
96109
}
97110

98111
/** Call once in +layout.svelte to create and register the store in context. */

src/routes/conversation/[id]/+page.svelte

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
import { useAPIClient, handleResponse } from "$lib/APIClient";
3838
import SharePreviewTags from "$lib/components/SharePreviewTags.svelte";
3939
40+
// IndexedDB persistence for offline fallback
41+
import { conversationRepository } from "$lib/repositories/ConversationRepository";
42+
import superjson from "superjson";
43+
4044
let { data } = $props();
4145
4246
// Obtain the conversations store during component init (context must be read
@@ -694,6 +698,24 @@
694698
rootMessageId = data.rootMessageId;
695699
_lastSyncedConvId = currentConvId;
696700
_lastSyncedMessages = newMessages;
701+
702+
// Persist the server-confirmed conversation to IndexedDB.
703+
// This fires whenever the load function returns fresh data (page
704+
// navigation or post-stream invalidation). Optimistic/transient
705+
// messages that haven't been acknowledged by the server are never
706+
// persisted — they remain only in the local `messages` state.
707+
if (browser && currentConvId) {
708+
void conversationRepository.setConversationDetail(currentConvId, {
709+
title: data.title,
710+
model: data.model,
711+
updatedAt: data.updatedAt.toISOString(),
712+
messages: superjson.stringify(newMessages),
713+
preprompt: data.preprompt,
714+
rootMessageId: data.rootMessageId,
715+
shared: data.shared,
716+
modelId: data.modelId,
717+
});
718+
}
697719
}
698720
});
699721

src/routes/conversation/[id]/+page.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { browser } from "$app/environment";
12
import { useAPIClient, handleResponse } from "$lib/APIClient";
23
import { UrlDependency } from "$lib/types/UrlDependency";
34
import { redirect } from "@sveltejs/kit";
45
import { base } from "$app/paths";
56
import type { PageLoad } from "./$types";
67
import type { Message } from "$lib/types/Message";
78
import type { DeployedSpace } from "$lib/types/Conversation";
9+
import { conversationRepository } from "$lib/repositories/ConversationRepository";
10+
import superjson from "superjson";
811

912
interface ConversationData {
1013
messages: Message[];
@@ -50,13 +53,55 @@ export const load: PageLoad = async ({ params, depends, fetch, url, parent }) =>
5053
}
5154
}
5255

53-
// Load conversation (works for both owned and shared conversations)
56+
// Cache-aside: try server first, fall back to IndexedDB on failure.
57+
// Server-confirmed data always overwrites local cache entries.
5458
try {
55-
return (await client
59+
const data = (await client
5660
.conversations({ id: params.id })
5761
.get({ query: { fromShare: url.searchParams.get("fromShare") ?? undefined } })
5862
.then(handleResponse)) as ConversationData;
59-
} catch {
63+
64+
// Persist server-confirmed data to IndexedDB for offline fallback.
65+
if (browser) {
66+
void conversationRepository.setConversationDetail(params.id, {
67+
title: data.title,
68+
model: data.model,
69+
updatedAt: data.updatedAt.toISOString(),
70+
messages: superjson.stringify(data.messages),
71+
preprompt: data.preprompt,
72+
rootMessageId: data.rootMessageId,
73+
shared: data.shared,
74+
modelId: data.modelId,
75+
});
76+
}
77+
78+
return data;
79+
} catch (serverErr) {
80+
// Network request failed; attempt to serve from IndexedDB cache.
81+
if (browser) {
82+
try {
83+
const cached = await conversationRepository.getConversationDetail(params.id);
84+
if (cached) {
85+
console.info("[conversation] serving from IndexedDB fallback for", params.id);
86+
return {
87+
id: cached.id,
88+
title: cached.title,
89+
model: cached.model,
90+
updatedAt: new Date(cached.updatedAt),
91+
messages: superjson.parse(cached.messages) as Message[],
92+
preprompt: cached.preprompt,
93+
rootMessageId: cached.rootMessageId,
94+
shared: cached.shared,
95+
modelId: cached.modelId,
96+
} satisfies ConversationData;
97+
}
98+
} catch (cacheErr) {
99+
console.error("[conversation] IndexedDB fallback also failed", cacheErr);
100+
}
101+
}
102+
103+
// No cache available either — redirect home.
104+
console.error("[conversation] load failed for", params.id, serverErr);
60105
redirect(302, `${base}/`);
61106
}
62107
};

0 commit comments

Comments
 (0)