fix(chat): migrate legacy cache to Elio conversation

This commit is contained in:
2026-07-17 19:04:41 +08:00
parent 5bfb71e2b8
commit a30e9937b8
7 changed files with 308 additions and 20 deletions
+1
View File
@@ -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",
+9
View File
@@ -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:
+3 -10
View File
@@ -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<ResultT<string>>;
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<ResultT<string>> {
@@ -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,
@@ -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<string>();
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<LocalMessageRow, number>("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<LocalChatMediaRow, string>("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<LocalMessageRow, number>("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<Dexie> {
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<T extends Dexie>(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,
};
}
+51 -1
View File
@@ -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<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;
}
+29
View File
@@ -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)
);
}