Files
cozsweet-frontend-nextjs/src/data/storage/chat/local_chat_db.ts
T

126 lines
3.7 KiB
TypeScript

"use client";
/**
* Dexie 数据库定义
*
* `messages.sessionId` 是当前聊天身份的缓存命名空间。所有消息读写必须通过
* 该索引限定身份,不能退化为整表操作。
*
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
*/
import Dexie, { type Table, type Transaction } from "dexie";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import type { ChatMediaKind } from "@/data/schemas/chat";
import type {
ChatImageData,
ChatLockDetailData,
} from "@/data/schemas/chat";
import {
buildChatConversationKey,
buildChatMediaCacheKey,
isLegacyChatCacheOwnerKey,
} from "@/lib/chat/chat_cache_keys";
export interface LocalMessageRow {
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
dbId?: number;
/** 消息 id(来自 ChatMessage.id)。 */
id: string;
role: string;
type: string;
content: string;
createdAt: string;
audioUrl?: string | null;
image?: ChatImageData;
lockDetail?: ChatLockDetailData;
sessionId: string;
}
export interface LocalChatMediaRow {
cacheKey: string;
ownerKey: string;
messageId: string;
kind: ChatMediaKind;
remoteUrl: string;
/** v3+ stores raw bytes instead of Blob to avoid iOS WebKit Blob URL issues. */
bytes?: ArrayBuffer;
/** Legacy v2 field. Read-only fallback for users who already have cached media. */
blob?: Blob;
mimeType: string;
byteSize: number;
createdAt: number;
updatedAt: number;
lastAccessedAt: number;
}
export class LocalChatDB extends Dexie {
messages!: Table<LocalMessageRow, number>;
media!: Table<LocalChatMediaRow, string>;
constructor(dbName: string = "cozsweet-chat") {
super(dbName);
this.version(1).stores({
messages: "++dbId",
});
this.version(2).stores({
messages: "++dbId",
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
});
this.version(3)
.stores({
messages: "++dbId, sessionId",
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
})
.upgrade(async (transaction) => {
// v1/v2 always wrote an empty sessionId. Its owner cannot be recovered,
// so retaining it would expose one identity's history to another.
await transaction.table("messages").clear();
});
this.version(4)
.stores({
messages: "++dbId, sessionId",
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
})
.upgrade(migrateLegacyChatCacheToElio);
}
}
async function migrateLegacyChatCacheToElio(
transaction: Transaction,
): Promise<void> {
const messages = transaction.table<LocalMessageRow, number>("messages");
await messages.toCollection().modify((message) => {
const conversationKey = getElioConversationKey(message.sessionId);
if (conversationKey) message.sessionId = conversationKey;
});
const media = transaction.table<LocalChatMediaRow, string>("media");
const rows = await media.toArray();
for (const row of rows) {
const conversationKey = getElioConversationKey(row.ownerKey);
if (!conversationKey) continue;
const cacheKey = buildChatMediaCacheKey({
ownerKey: conversationKey,
messageId: row.messageId,
kind: row.kind,
});
const existing = await media.get(cacheKey);
await media.delete(row.cacheKey);
if (existing) continue;
await media.put({
...row,
cacheKey,
ownerKey: conversationKey,
});
}
}
function getElioConversationKey(ownerKey: string): string | null {
return isLegacyChatCacheOwnerKey(ownerKey)
? buildChatConversationKey(ownerKey, DEFAULT_CHARACTER_ID)
: null;
}