fix(chat): migrate legacy cache to Elio conversation
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user