refactor: migrate snapshot storage from localStorage to IndexedDB

To improve data consistency and reliability, snapshot storage has been migrated from localStorage to IndexedDB. This change includes adding a new 'snapshots' object store, updating database version to 2, and modifying related functions to use IndexedDB for snapshot operations. The migration ensures better handling of snapshots alongside chat data and removes dependency on localStorage preventing UI lag.
This commit is contained in:
KevIsDev 2025-04-23 12:17:06 +01:00
parent 5c44cb4e00
commit fe37f5ceea
2 changed files with 144 additions and 32 deletions

View File

@ -1,6 +1,7 @@
import type { Message } from 'ai';
import { createScopedLogger } from '~/utils/logger';
import type { ChatHistoryItem } from './useChatHistory';
import type { Snapshot } from './types'; // Import Snapshot type
export interface IChatMetadata {
gitUrl: string;
@ -18,15 +19,24 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
}
return new Promise((resolve) => {
const request = indexedDB.open('boltHistory', 1);
const request = indexedDB.open('boltHistory', 2);
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = event.oldVersion;
if (!db.objectStoreNames.contains('chats')) {
const store = db.createObjectStore('chats', { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
store.createIndex('urlId', 'urlId', { unique: true });
if (oldVersion < 1) {
if (!db.objectStoreNames.contains('chats')) {
const store = db.createObjectStore('chats', { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
store.createIndex('urlId', 'urlId', { unique: true });
}
}
if (oldVersion < 2) {
if (!db.objectStoreNames.contains('snapshots')) {
db.createObjectStore('snapshots', { keyPath: 'chatId' });
}
}
};
@ -113,12 +123,46 @@ export async function getMessagesById(db: IDBDatabase, id: string): Promise<Chat
export async function deleteById(db: IDBDatabase, id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readwrite');
const store = transaction.objectStore('chats');
const request = store.delete(id);
const transaction = db.transaction(['chats', 'snapshots'], 'readwrite'); // Add snapshots store to transaction
const chatStore = transaction.objectStore('chats');
const snapshotStore = transaction.objectStore('snapshots');
request.onsuccess = () => resolve(undefined);
request.onerror = () => reject(request.error);
const deleteChatRequest = chatStore.delete(id);
const deleteSnapshotRequest = snapshotStore.delete(id); // Also delete snapshot
let chatDeleted = false;
let snapshotDeleted = false;
const checkCompletion = () => {
if (chatDeleted && snapshotDeleted) {
resolve(undefined);
}
};
deleteChatRequest.onsuccess = () => {
chatDeleted = true;
checkCompletion();
};
deleteChatRequest.onerror = () => reject(deleteChatRequest.error);
deleteSnapshotRequest.onsuccess = () => {
snapshotDeleted = true;
checkCompletion();
};
deleteSnapshotRequest.onerror = (event) => {
if ((event.target as IDBRequest).error?.name === 'NotFoundError') {
snapshotDeleted = true;
checkCompletion();
} else {
reject(deleteSnapshotRequest.error);
}
};
transaction.oncomplete = () => {
// This might resolve before checkCompletion if one operation finishes much faster
};
transaction.onerror = () => reject(transaction.error);
});
}
@ -257,3 +301,43 @@ export async function updateChatMetadata(
await setMessages(db, id, chat.messages, chat.urlId, chat.description, chat.timestamp, metadata);
}
export async function getSnapshot(db: IDBDatabase, chatId: string): Promise<Snapshot | undefined> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('snapshots', 'readonly');
const store = transaction.objectStore('snapshots');
const request = store.get(chatId);
request.onsuccess = () => resolve(request.result?.snapshot as Snapshot | undefined);
request.onerror = () => reject(request.error);
});
}
export async function setSnapshot(db: IDBDatabase, chatId: string, snapshot: Snapshot): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('snapshots', 'readwrite');
const store = transaction.objectStore('snapshots');
const request = store.put({ chatId, snapshot });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function deleteSnapshot(db: IDBDatabase, chatId: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('snapshots', 'readwrite');
const store = transaction.objectStore('snapshots');
const request = store.delete(chatId);
request.onsuccess = () => resolve();
request.onerror = (event) => {
if ((event.target as IDBRequest).error?.name === 'NotFoundError') {
resolve();
} else {
reject(request.error);
}
};
});
}

View File

@ -13,6 +13,8 @@ import {
setMessages,
duplicateChat,
createChatFromMessages,
getSnapshot,
setSnapshot,
type IChatMetadata,
} from './db';
import type { FileMap } from '~/lib/stores/files';
@ -61,19 +63,25 @@ export function useChatHistory() {
}
if (mixedId) {
getMessages(db, mixedId)
.then(async (storedMessages) => {
Promise.all([
getMessages(db, mixedId),
getSnapshot(db, mixedId), // Fetch snapshot from DB
])
.then(async ([storedMessages, snapshot]) => {
if (storedMessages && storedMessages.messages.length > 0) {
const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`);
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
const summary = snapshot.summary;
/*
* const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`); // Remove localStorage usage
* const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} }; // Use snapshot from DB
*/
const validSnapshot = snapshot || { chatIndex: '', files: {} }; // Ensure snapshot is not undefined
const summary = validSnapshot.summary;
const rewindId = searchParams.get('rewindTo');
let startingIdx = -1;
const endingIdx = rewindId
? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
: storedMessages.messages.length;
const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === snapshot.chatIndex);
const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === validSnapshot.chatIndex);
if (snapshotIndex >= 0 && snapshotIndex < endingIdx) {
startingIdx = snapshotIndex;
@ -93,7 +101,7 @@ export function useChatHistory() {
setArchivedMessages(archivedMessages);
if (startingIdx > 0) {
const files = Object.entries(snapshot?.files || {})
const files = Object.entries(validSnapshot?.files || {})
.map(([key, value]) => {
if (value?.type !== 'file') {
return null;
@ -197,17 +205,20 @@ ${value.content}
.catch((error) => {
console.error(error);
logStore.logError('Failed to load chat messages', error);
toast.error(error.message);
logStore.logError('Failed to load chat messages or snapshot', error); // Updated error message
toast.error('Failed to load chat: ' + error.message); // More specific error
});
} else {
// Handle case where there is no mixedId (e.g., new chat)
setReady(true);
}
}, [mixedId]);
}, [mixedId, db, navigate, searchParams]); // Added db, navigate, searchParams dependencies
const takeSnapshot = useCallback(
async (chatIdx: string, files: FileMap, _chatId?: string | undefined, chatSummary?: string) => {
const id = _chatId || chatId;
const id = _chatId || chatId.get();
if (!id) {
if (!id || !db) {
return;
}
@ -216,23 +227,29 @@ ${value.content}
files,
summary: chatSummary,
};
localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot));
// localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot)); // Remove localStorage usage
try {
await setSnapshot(db, id, snapshot);
} catch (error) {
console.error('Failed to save snapshot:', error);
toast.error('Failed to save chat snapshot.');
}
},
[chatId],
[db],
);
const restoreSnapshot = useCallback(async (id: string) => {
const snapshotStr = localStorage.getItem(`snapshot:${id}`);
const restoreSnapshot = useCallback(async (id: string, snapshot?: Snapshot) => {
// const snapshotStr = localStorage.getItem(`snapshot:${id}`); // Remove localStorage usage
const container = await webcontainer;
// if (snapshotStr)setSnapshot(JSON.parse(snapshotStr));
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
const validSnapshot = snapshot || { chatIndex: '', files: {} };
if (!snapshot?.files) {
if (!validSnapshot?.files) {
return;
}
Object.entries(snapshot.files).forEach(async ([key, value]) => {
Object.entries(validSnapshot.files).forEach(async ([key, value]) => {
if (key.startsWith(container.workdir)) {
key = key.replace(container.workdir, '');
}
@ -241,7 +258,7 @@ ${value.content}
await container.fs.mkdir(key, { recursive: true });
}
});
Object.entries(snapshot.files).forEach(async ([key, value]) => {
Object.entries(validSnapshot.files).forEach(async ([key, value]) => {
if (value?.type === 'file') {
if (key.startsWith(container.workdir)) {
key = key.replace(container.workdir, '');
@ -311,6 +328,7 @@ ${value.content}
description.set(firstArtifact?.title);
}
// Ensure chatId.get() is used here as well
if (initialMessages.length === 0 && !chatId.get()) {
const nextId = await getNextId(db);
@ -321,9 +339,19 @@ ${value.content}
}
}
// Ensure chatId.get() is used for the final setMessages call
const finalChatId = chatId.get();
if (!finalChatId) {
console.error('Cannot save messages, chat ID is not set.');
toast.error('Failed to save chat messages: Chat ID missing.');
return;
}
await setMessages(
db,
chatId.get() as string,
finalChatId, // Use the potentially updated chatId
[...archivedMessages, ...messages],
urlId,
description.get(),