gitdataai/src/lib/storage/indexed-db.ts
ZhenYi 309bc50e86 perf(room): prioritize IndexedDB cache for instant loads
- Remove clearRoomMessages on room switch: IDB cache now persists across
  visits, enabling instant re-entry without API round-trip
- Increase initial API limit from 50 → 200: more messages cached upfront
- Add loadOlderMessagesFromIdb: uses 'by_room_seq' compound index to
  serve scroll-back history from IDB without API call
- loadMore now tries IDB first before falling back to API
2026-04-17 21:36:55 +08:00

233 lines
7.1 KiB
TypeScript

/**
* IndexedDB storage for room messages.
* Provides persistent message storage that survives page reloads and works offline.
*/
import type { MessageWithMeta } from '@/contexts/room-context';
import type { ReactionGroup } from '@/contexts';
const DB_NAME = 'room-messages';
const DB_VERSION = 1;
const STORE_MESSAGES = 'messages';
const STORE_REACTIONS = 'reactions';
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Messages store: keyed by message_id, indexed by room_id and seq
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
const msgStore = db.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
msgStore.createIndex('by_room', 'room', { unique: false });
msgStore.createIndex('by_room_seq', ['room', 'seq'], { unique: false });
}
// Reactions store: keyed by message_id
if (!db.objectStoreNames.contains(STORE_REACTIONS)) {
db.createObjectStore(STORE_REACTIONS, { keyPath: 'message_id' });
}
};
});
return dbPromise;
}
export interface StoredMessage {
id: string;
room: string;
seq: number;
sender_type: string;
sender_id?: string;
display_name?: string;
thread_id?: string;
content: string;
content_type: string;
edited_at?: string;
send_at: string;
revoked?: string;
revoked_by?: string;
reactions?: ReactionGroup[];
}
function msgToStored(msg: MessageWithMeta): StoredMessage {
return {
id: msg.id,
room: msg.room,
seq: msg.seq,
sender_type: msg.sender_type,
sender_id: msg.sender_id ?? undefined,
display_name: msg.display_name ?? undefined,
thread_id: msg.thread_id,
content: msg.content,
content_type: msg.content_type,
edited_at: msg.edited_at ?? undefined,
send_at: msg.send_at,
revoked: msg.revoked ?? undefined,
revoked_by: msg.revoked_by ?? undefined,
reactions: msg.reactions,
};
}
function storedToMsg(stored: StoredMessage): MessageWithMeta {
return {
...stored,
thread: stored.thread_id,
display_content: stored.content,
is_streaming: false,
};
}
/** Save a single message to IndexedDB */
export async function saveMessage(msg: MessageWithMeta): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readwrite');
tx.objectStore(STORE_MESSAGES).put(msgToStored(msg));
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn('[IDB] saveMessage failed:', err);
}
}
/** Save multiple messages in a single transaction */
export async function saveMessages(_roomId: string, msgs: MessageWithMeta[]): Promise<void> {
if (msgs.length === 0) return;
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readwrite');
const store = tx.objectStore(STORE_MESSAGES);
for (const msg of msgs) {
store.put(msgToStored(msg));
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn('[IDB] saveMessages failed:', err);
}
}
/** Load all messages for a room from IndexedDB, sorted by seq ascending */
export async function loadMessages(roomId: string): Promise<MessageWithMeta[]> {
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readonly');
const index = tx.objectStore(STORE_MESSAGES).index('by_room');
const request = index.getAll(IDBKeyRange.only(roomId));
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const results: StoredMessage[] = request.result;
const msgs = results.map(storedToMsg);
msgs.sort((a, b) => a.seq - b.seq);
resolve(msgs);
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.warn('[IDB] loadMessages failed:', err);
return [];
}
}
/** Delete a message by id */
export async function deleteMessage(messageId: string): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readwrite');
tx.objectStore(STORE_MESSAGES).delete(messageId);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn('[IDB] deleteMessage failed:', err);
}
}
/** Clear all messages for a room */
export async function clearRoomMessages(roomId: string): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readwrite');
const index = tx.objectStore(STORE_MESSAGES).index('by_room');
const request = index.openCursor(IDBKeyRange.only(roomId));
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn('[IDB] clearRoomMessages failed:', err);
}
}
/** Load older messages from IDB (seq < beforeSeq), sorted ascending, up to `limit` */
export async function loadOlderMessagesFromIdb(
roomId: string,
beforeSeq: number,
limit = 50,
): Promise<MessageWithMeta[]> {
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readonly');
const index = tx.objectStore(STORE_MESSAGES).index('by_room_seq');
// Compound key range: roomId + any seq less than beforeSeq
const range = IDBKeyRange.bound([roomId, 0], [roomId, beforeSeq - 1]);
const request = index.openCursor(range, 'prev'); // 'prev' = descending seq (newest first)
// We want oldest before `beforeSeq`, so after getting `limit` items in 'prev' order,
// reverse to ascending seq.
const collected: StoredMessage[] = [];
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result;
if (cursor && collected.length < limit) {
collected.push(cursor.value);
cursor.continue();
} else {
// Reverse back to ascending order (oldest first)
const msgs = collected.reverse().map(storedToMsg);
resolve(msgs);
}
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.warn('[IDB] loadOlderMessagesFromIdb failed:', err);
return [];
}
}
/** Get the highest seq number for a room (for dedup) */
export async function getMaxSeq(roomId: string): Promise<number> {
try {
const msgs = await loadMessages(roomId);
return msgs.length > 0 ? msgs[msgs.length - 1].seq : -1;
} catch {
return -1;
}
}