From a30e9937b8c0c467af041dd931763d7e7fffa732 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 17 Jul 2026 19:04:41 +0800 Subject: [PATCH] fix(chat): migrate legacy cache to Elio conversation --- package.json | 1 + pnpm-lock.yaml | 9 + src/data/repositories/chat_cache_identity.ts | 13 +- .../repositories/chat_media_cache_helpers.ts | 11 +- .../__tests__/local_chat_db_migration.test.ts | 213 ++++++++++++++++++ src/data/storage/chat/local_chat_db.ts | 52 ++++- src/lib/chat/chat_cache_keys.ts | 29 +++ 7 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 src/data/storage/chat/__tests__/local_chat_db_migration.test.ts create mode 100644 src/lib/chat/chat_cache_keys.ts diff --git a/package.json b/package.json index 9bb016a0..9bbc58c3 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "barrelsby": "^2.8.1", "eslint": "^9", "eslint-config-next": "16.2.7", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79b05f38..dcc6b8da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: eslint-config-next: specifier: 16.2.7 version: 16.2.7(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -2339,6 +2342,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6193,6 +6200,8 @@ snapshots: expect-type@1.3.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: diff --git a/src/data/repositories/chat_cache_identity.ts b/src/data/repositories/chat_cache_identity.ts index 52796fb8..528aaa3a 100644 --- a/src/data/repositories/chat_cache_identity.ts +++ b/src/data/repositories/chat_cache_identity.ts @@ -1,8 +1,11 @@ import { LoginStatus } from "@/data/schemas/auth"; import { AuthStorage, type IAuthStorage } from "@/data/storage/auth"; import { UserStorage, type IUserStorage } from "@/data/storage/user"; +import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys"; import { Result, type Result as ResultT } from "@/utils/result"; +export { buildChatConversationKey } from "@/lib/chat/chat_cache_keys"; + export type ChatCacheIdentityResolver = () => Promise>; export type ChatConversationKeyResolver = ( characterId: string, @@ -53,16 +56,6 @@ export async function resolveChatCacheOwnerKey( return Result.err(new Error("Chat cache identity is unavailable.")); } -export function buildChatConversationKey( - ownerKey: string, - characterId: string, -): string { - if (ownerKey.trim().length === 0 || characterId.trim().length === 0) { - throw new Error("Chat owner and character identities must not be empty."); - } - return `${ownerKey}::character:${encodeURIComponent(characterId)}`; -} - export async function resolveChatConversationKey( characterId: string, ): Promise> { diff --git a/src/data/repositories/chat_media_cache_helpers.ts b/src/data/repositories/chat_media_cache_helpers.ts index 71ff7e15..d2969ae5 100644 --- a/src/data/repositories/chat_media_cache_helpers.ts +++ b/src/data/repositories/chat_media_cache_helpers.ts @@ -5,16 +5,9 @@ import type { } from "@/data/schemas/chat"; import type { CacheRemoteChatMediaInput } from "@/data/repositories/interfaces"; import { isCacheableRemoteChatMediaUrl } from "@/lib/chat/chat_media_url"; +import { buildChatMediaCacheKey } from "@/lib/chat/chat_cache_keys"; -export { isCacheableRemoteChatMediaUrl }; - -export function buildChatMediaCacheKey(input: { - ownerKey: string; - messageId: string; - kind: ChatMediaKind; -}): string { - return `${input.ownerKey}:${input.messageId}:${input.kind}`; -} +export { buildChatMediaCacheKey, isCacheableRemoteChatMediaUrl }; export function getMessageMediaTargets( message: ChatMessage, diff --git a/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts b/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts new file mode 100644 index 00000000..0c9d6eff --- /dev/null +++ b/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts @@ -0,0 +1,213 @@ +import "fake-indexeddb/auto"; + +import Dexie from "dexie"; +import { afterEach, describe, expect, it } from "vitest"; + +import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; +import { + buildChatConversationKey, + buildChatMediaCacheKey, +} from "@/lib/chat/chat_cache_keys"; + +import { + LocalChatDB, + type LocalChatMediaRow, + type LocalMessageRow, +} from "../local_chat_db"; + +const DATABASE_SCHEMA = { + messages: "++dbId, sessionId", + media: + "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", +}; + +const openDatabases: Dexie[] = []; +const databaseNames = new Set(); + +afterEach(async () => { + for (const database of openDatabases.splice(0)) database.close(); + await Promise.all([...databaseNames].map((name) => Dexie.delete(name))); + databaseNames.clear(); +}); + +describe("LocalChatDB v4 migration", () => { + it("moves legacy user and guest messages into the Elio conversation", async () => { + const legacy = await createLegacyV3Database(); + const mayaConversation = buildChatConversationKey( + "user:maya-owner", + "character_maya", + ); + await legacy.table("messages").bulkAdd([ + messageRow("message-user", "user:account-1"), + messageRow("message-guest", "device:guest-1"), + messageRow("message-maya", mayaConversation), + ]); + legacy.close(); + + const upgraded = track(new LocalChatDB(legacy.name)); + await upgraded.open(); + + const rows = await upgraded.messages.orderBy("dbId").toArray(); + expect(rows.map(({ id, sessionId }) => ({ id, sessionId }))).toEqual([ + { + id: "message-user", + sessionId: buildChatConversationKey( + "user:account-1", + DEFAULT_CHARACTER_ID, + ), + }, + { + id: "message-guest", + sessionId: buildChatConversationKey( + "device:guest-1", + DEFAULT_CHARACTER_ID, + ), + }, + { id: "message-maya", sessionId: mayaConversation }, + ]); + }); + + it("rekeys legacy media without overwriting existing scoped media", async () => { + const legacy = await createLegacyV3Database(); + const legacyOwner = "user:account-1"; + const elioConversation = buildChatConversationKey( + legacyOwner, + DEFAULT_CHARACTER_ID, + ); + const elioCacheKey = buildChatMediaCacheKey({ + ownerKey: elioConversation, + messageId: "shared-message", + kind: "image", + }); + const mayaConversation = buildChatConversationKey( + legacyOwner, + "character_maya", + ); + const mayaCacheKey = buildChatMediaCacheKey({ + ownerKey: mayaConversation, + messageId: "maya-message", + kind: "audio", + }); + await legacy.table("media").bulkAdd([ + mediaRow({ + cacheKey: `${legacyOwner}:shared-message:image`, + ownerKey: legacyOwner, + messageId: "shared-message", + kind: "image", + remoteUrl: "https://example.com/legacy.jpg", + }), + mediaRow({ + cacheKey: elioCacheKey, + ownerKey: elioConversation, + messageId: "shared-message", + kind: "image", + remoteUrl: "https://example.com/current.jpg", + }), + mediaRow({ + cacheKey: "device:guest-1:guest-message:audio", + ownerKey: "device:guest-1", + messageId: "guest-message", + kind: "audio", + remoteUrl: "https://example.com/guest.mp3", + }), + mediaRow({ + cacheKey: mayaCacheKey, + ownerKey: mayaConversation, + messageId: "maya-message", + kind: "audio", + remoteUrl: "https://example.com/maya.mp3", + }), + ]); + legacy.close(); + + const upgraded = track(new LocalChatDB(legacy.name)); + await upgraded.open(); + + const rows = await upgraded.media.toArray(); + expect(rows).toHaveLength(3); + expect(await upgraded.media.get(`${legacyOwner}:shared-message:image`)).toBe( + undefined, + ); + expect(await upgraded.media.get(elioCacheKey)).toMatchObject({ + ownerKey: elioConversation, + remoteUrl: "https://example.com/current.jpg", + }); + + const guestConversation = buildChatConversationKey( + "device:guest-1", + DEFAULT_CHARACTER_ID, + ); + const guestCacheKey = buildChatMediaCacheKey({ + ownerKey: guestConversation, + messageId: "guest-message", + kind: "audio", + }); + expect(await upgraded.media.get(guestCacheKey)).toMatchObject({ + cacheKey: guestCacheKey, + ownerKey: guestConversation, + remoteUrl: "https://example.com/guest.mp3", + }); + expect(await upgraded.media.get(mayaCacheKey)).toMatchObject({ + ownerKey: mayaConversation, + }); + }); + + it("does not run the migration again after the database reaches v4", async () => { + const legacy = await createLegacyV3Database(); + await legacy + .table("messages") + .add(messageRow("message-user", "user:account-1")); + legacy.close(); + + const upgraded = track(new LocalChatDB(legacy.name)); + await upgraded.open(); + const firstRows = await upgraded.messages.toArray(); + upgraded.close(); + + const reopened = track(new LocalChatDB(legacy.name)); + await reopened.open(); + expect(await reopened.messages.toArray()).toEqual(firstRows); + }); +}); + +async function createLegacyV3Database(): Promise { + const name = `cozsweet-chat-v3-${crypto.randomUUID()}`; + databaseNames.add(name); + const database = track(new Dexie(name)); + database.version(3).stores(DATABASE_SCHEMA); + await database.open(); + return database; +} + +function track(database: T): T { + openDatabases.push(database); + return database; +} + +function messageRow(id: string, sessionId: string): LocalMessageRow { + return { + id, + role: "assistant", + type: "text", + content: id, + createdAt: "2026-07-17T00:00:00.000Z", + sessionId, + }; +} + +function mediaRow( + input: Pick< + LocalChatMediaRow, + "cacheKey" | "ownerKey" | "messageId" | "kind" | "remoteUrl" + >, +): LocalChatMediaRow { + return { + ...input, + bytes: new Uint8Array([1, 2, 3]).buffer, + mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg", + byteSize: 3, + createdAt: 1, + updatedAt: 1, + lastAccessedAt: 1, + }; +} diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index 3da6e2d5..a3364577 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -9,12 +9,18 @@ * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 */ -import Dexie, { type Table } from "dexie"; +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 类)。 */ @@ -71,5 +77,49 @@ export class LocalChatDB extends Dexie { // 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 { + const messages = transaction.table("messages"); + await messages.toCollection().modify((message) => { + const conversationKey = getElioConversationKey(message.sessionId); + if (conversationKey) message.sessionId = conversationKey; + }); + + const media = transaction.table("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; +} diff --git a/src/lib/chat/chat_cache_keys.ts b/src/lib/chat/chat_cache_keys.ts new file mode 100644 index 00000000..f9e4291b --- /dev/null +++ b/src/lib/chat/chat_cache_keys.ts @@ -0,0 +1,29 @@ +import type { ChatMediaKind } from "@/data/schemas/chat"; + +const CHARACTER_NAMESPACE = "::character:"; +const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u; + +export function buildChatConversationKey( + ownerKey: string, + characterId: string, +): string { + if (ownerKey.trim().length === 0 || characterId.trim().length === 0) { + throw new Error("Chat owner and character identities must not be empty."); + } + return `${ownerKey}${CHARACTER_NAMESPACE}${encodeURIComponent(characterId)}`; +} + +export function buildChatMediaCacheKey(input: { + ownerKey: string; + messageId: string; + kind: ChatMediaKind; +}): string { + return `${input.ownerKey}:${input.messageId}:${input.kind}`; +} + +export function isLegacyChatCacheOwnerKey(ownerKey: string): boolean { + return ( + !ownerKey.includes(CHARACTER_NAMESPACE) && + LEGACY_OWNER_KEY_PATTERN.test(ownerKey) + ); +}