fix(chat): isolate cached data by identity
Scope message and media caches to the active user or guest identity. Preserve identity snapshots across async chat flows and discard legacy unscoped message data during the IndexedDB upgrade.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LoginStatus } from "@/data/dto/auth";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import { resolveChatCacheOwnerKey } from "../chat_cache_identity";
|
||||
|
||||
function createStorageState(input: {
|
||||
provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null;
|
||||
userId?: string | null;
|
||||
deviceId?: string | null;
|
||||
}) {
|
||||
return {
|
||||
authStorage: {
|
||||
getLoginProvider: vi.fn(async () => Result.ok(input.provider)),
|
||||
getDeviceId: vi.fn(async () => Result.ok(input.deviceId ?? null)),
|
||||
},
|
||||
userStorage: {
|
||||
getUserId: vi.fn(async () => Result.ok(input.userId ?? null)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("chat cache identity", () => {
|
||||
it("uses the current user id for authenticated and guest sessions", async () => {
|
||||
const authenticated = createStorageState({
|
||||
provider: LoginStatus.Email,
|
||||
userId: "account-1",
|
||||
deviceId: "shared-device",
|
||||
});
|
||||
const guest = createStorageState({
|
||||
provider: LoginStatus.Guest,
|
||||
userId: "guest-1",
|
||||
deviceId: "shared-device",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveChatCacheOwnerKey(
|
||||
authenticated.authStorage,
|
||||
authenticated.userStorage,
|
||||
),
|
||||
).resolves.toEqual(Result.ok("user:account-1"));
|
||||
await expect(
|
||||
resolveChatCacheOwnerKey(guest.authStorage, guest.userStorage),
|
||||
).resolves.toEqual(Result.ok("user:guest-1"));
|
||||
});
|
||||
|
||||
it("uses a device namespace only when the active session is guest", async () => {
|
||||
const guest = createStorageState({
|
||||
provider: LoginStatus.Guest,
|
||||
deviceId: "guest-device",
|
||||
});
|
||||
const authenticated = createStorageState({
|
||||
provider: LoginStatus.Google,
|
||||
deviceId: "shared-device",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveChatCacheOwnerKey(guest.authStorage, guest.userStorage),
|
||||
).resolves.toEqual(Result.ok("device:guest-device"));
|
||||
|
||||
const authenticatedResult = await resolveChatCacheOwnerKey(
|
||||
authenticated.authStorage,
|
||||
authenticated.userStorage,
|
||||
);
|
||||
expect(Result.isErr(authenticatedResult)).toBe(true);
|
||||
expect(authenticated.authStorage.getDeviceId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not expose a cache while logged out", async () => {
|
||||
const loggedOut = createStorageState({
|
||||
provider: LoginStatus.NotLoggedIn,
|
||||
userId: "stale-user",
|
||||
deviceId: "shared-device",
|
||||
});
|
||||
|
||||
const result = await resolveChatCacheOwnerKey(
|
||||
loggedOut.authStorage,
|
||||
loggedOut.userStorage,
|
||||
);
|
||||
|
||||
expect(Result.isErr(result)).toBe(true);
|
||||
expect(loggedOut.userStorage.getUserId).not.toHaveBeenCalled();
|
||||
expect(loggedOut.authStorage.getDeviceId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatMessage } from "@/data/dto/chat";
|
||||
import { LocalMessage } from "@/data/storage/chat";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import { ChatLocalMessageStore } from "../chat_local_message_store";
|
||||
|
||||
function makeMessage(id: string, content: string): ChatMessage {
|
||||
return ChatMessage.from({
|
||||
id,
|
||||
role: "assistant",
|
||||
type: "text",
|
||||
content,
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
audioUrl: null,
|
||||
image: { type: null, url: null },
|
||||
lockDetail: { locked: false, reason: null },
|
||||
});
|
||||
}
|
||||
|
||||
function createMemoryStorage() {
|
||||
const buckets = new Map<string, LocalMessage[]>();
|
||||
return {
|
||||
buckets,
|
||||
storage: {
|
||||
saveMessage: vi.fn(async (message: LocalMessage) => {
|
||||
const messages = buckets.get(message.sessionId) ?? [];
|
||||
buckets.set(message.sessionId, [...messages, message]);
|
||||
return Result.ok(undefined);
|
||||
}),
|
||||
replaceMessagesBySession: vi.fn(
|
||||
async (identity: string, messages: readonly LocalMessage[]) => {
|
||||
buckets.set(identity, [...messages]);
|
||||
return Result.ok(undefined);
|
||||
},
|
||||
),
|
||||
getAllMessagesBySession: vi.fn(async (identity: string) =>
|
||||
Result.ok([...(buckets.get(identity) ?? [])]),
|
||||
),
|
||||
clearMessagesBySession: vi.fn(async (identity: string) => {
|
||||
buckets.delete(identity);
|
||||
return Result.ok(undefined);
|
||||
}),
|
||||
getMessageCountBySession: vi.fn(async (identity: string) =>
|
||||
Result.ok(buckets.get(identity)?.length ?? 0),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ChatLocalMessageStore identity isolation", () => {
|
||||
it("reads, replaces, and clears only the current identity", async () => {
|
||||
const memory = createMemoryStorage();
|
||||
let identity = "user:account-a";
|
||||
const store = new ChatLocalMessageStore(
|
||||
memory.storage,
|
||||
async () => Result.ok(identity),
|
||||
);
|
||||
|
||||
await store.saveMessages([makeMessage("a-1", "account A")]);
|
||||
|
||||
identity = "user:account-b";
|
||||
await expect(store.getMessages()).resolves.toEqual(Result.ok([]));
|
||||
await store.saveMessages([makeMessage("b-1", "account B")]);
|
||||
await store.clearMessages();
|
||||
|
||||
identity = "user:account-a";
|
||||
const accountAResult = await store.getMessages();
|
||||
expect(Result.isOk(accountAResult) && accountAResult.data).toMatchObject([
|
||||
{ id: "a-1", content: "account A" },
|
||||
]);
|
||||
expect(memory.buckets.has("user:account-b")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps one identity snapshot throughout an unlock update", async () => {
|
||||
const memory = createMemoryStorage();
|
||||
memory.buckets.set("user:account-a", [
|
||||
LocalMessage.from({
|
||||
id: "locked-a",
|
||||
role: "assistant",
|
||||
type: "image",
|
||||
content: "locked",
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
audioUrl: null,
|
||||
image: { type: "jpg", url: "https://example.com/a.jpg" },
|
||||
lockDetail: { locked: true, reason: "image_paywall" },
|
||||
sessionId: "user:account-a",
|
||||
}),
|
||||
]);
|
||||
memory.buckets.set("user:account-b", []);
|
||||
const resolveIdentity = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Result.ok("user:account-a"))
|
||||
.mockResolvedValue(Result.ok("user:account-b"));
|
||||
const store = new ChatLocalMessageStore(memory.storage, resolveIdentity);
|
||||
|
||||
const result = await store.markMessageUnlocked("locked-a");
|
||||
|
||||
expect(Result.isOk(result)).toBe(true);
|
||||
expect(resolveIdentity).toHaveBeenCalledTimes(1);
|
||||
expect(memory.buckets.get("user:account-a")?.[0]?.lockDetail.locked).toBe(
|
||||
false,
|
||||
);
|
||||
expect(memory.buckets.get("user:account-b")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
||||
|
||||
describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
it("looks up media only in the current identity namespace", async () => {
|
||||
const getMedia = vi.fn(async () => Result.ok(null));
|
||||
const mediaStorage = {
|
||||
getMedia,
|
||||
saveMedia: vi.fn(async () => Result.ok(undefined)),
|
||||
};
|
||||
const coordinator = new ChatMediaCacheCoordinator(
|
||||
mediaStorage,
|
||||
async () => Result.ok("user:account-a"),
|
||||
);
|
||||
|
||||
const result = await coordinator.getCachedMedia({
|
||||
messageId: "message-1",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
expect(result).toEqual(Result.ok(null));
|
||||
expect(getMedia).toHaveBeenCalledTimes(1);
|
||||
expect(getMedia).toHaveBeenCalledWith("user:account-a:message-1:image");
|
||||
});
|
||||
|
||||
it("does not fall back to an anonymous cache without an identity", async () => {
|
||||
const getMedia = vi.fn(async () => Result.ok(null));
|
||||
const coordinator = new ChatMediaCacheCoordinator(
|
||||
{
|
||||
getMedia,
|
||||
saveMedia: vi.fn(async () => Result.ok(undefined)),
|
||||
},
|
||||
async () => Result.err(new Error("identity unavailable")),
|
||||
);
|
||||
|
||||
const result = await coordinator.getCachedMedia({
|
||||
messageId: "message-1",
|
||||
kind: "audio",
|
||||
});
|
||||
|
||||
expect(Result.isErr(result)).toBe(true);
|
||||
expect(getMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { LoginStatus } from "@/data/dto/auth";
|
||||
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
|
||||
import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
||||
import { Result, type Result as ResultT } from "@/utils";
|
||||
|
||||
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
|
||||
|
||||
type ChatCacheAuthStorage = Pick<
|
||||
IAuthStorage,
|
||||
"getLoginProvider" | "getDeviceId"
|
||||
>;
|
||||
type ChatCacheUserStorage = Pick<IUserStorage, "getUserId">;
|
||||
|
||||
/**
|
||||
* Resolve the only identity allowed to access the current chat cache.
|
||||
*
|
||||
* A user id is shared by neither account switches nor guest/account changes,
|
||||
* so it is the preferred namespace for both authenticated and guest users.
|
||||
* The device id is only a safe fallback for an active guest session. Real
|
||||
* accounts without a persisted user id must skip the cache instead of falling
|
||||
* back to a device-wide or anonymous namespace.
|
||||
*/
|
||||
export async function resolveChatCacheOwnerKey(
|
||||
authStorage: ChatCacheAuthStorage = AuthStorage.getInstance(),
|
||||
userStorage: ChatCacheUserStorage = UserStorage.getInstance(),
|
||||
): Promise<ResultT<string>> {
|
||||
const providerResult = await authStorage.getLoginProvider();
|
||||
if (Result.isErr(providerResult)) return providerResult;
|
||||
if (
|
||||
providerResult.data === null ||
|
||||
providerResult.data === LoginStatus.NotLoggedIn
|
||||
) {
|
||||
return Result.err(new Error("Chat cache identity is unavailable."));
|
||||
}
|
||||
|
||||
const userIdResult = await userStorage.getUserId();
|
||||
if (Result.isErr(userIdResult)) return userIdResult;
|
||||
if (userIdResult.data && userIdResult.data.length > 0) {
|
||||
return Result.ok(`user:${userIdResult.data}`);
|
||||
}
|
||||
|
||||
if (providerResult.data === LoginStatus.Guest) {
|
||||
const deviceIdResult = await authStorage.getDeviceId();
|
||||
if (Result.isErr(deviceIdResult)) return deviceIdResult;
|
||||
if (deviceIdResult.data && deviceIdResult.data.length > 0) {
|
||||
return Result.ok(`device:${deviceIdResult.data}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.err(new Error("Chat cache identity is unavailable."));
|
||||
}
|
||||
@@ -5,15 +5,35 @@ import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/inter
|
||||
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import {
|
||||
resolveChatCacheOwnerKey,
|
||||
type ChatCacheIdentityResolver,
|
||||
} from "./chat_cache_identity";
|
||||
|
||||
type LocalMessageStorage = Pick<
|
||||
LocalChatStorage,
|
||||
| "saveMessage"
|
||||
| "replaceMessagesBySession"
|
||||
| "getAllMessagesBySession"
|
||||
| "clearMessagesBySession"
|
||||
| "getMessageCountBySession"
|
||||
>;
|
||||
|
||||
export class ChatLocalMessageStore {
|
||||
constructor(private readonly localStorage: LocalChatStorage) {}
|
||||
constructor(
|
||||
private readonly localStorage: LocalMessageStorage,
|
||||
private readonly resolveIdentity: ChatCacheIdentityResolver =
|
||||
resolveChatCacheOwnerKey,
|
||||
) {}
|
||||
|
||||
async markMessageUnlocked(
|
||||
messageId: string,
|
||||
patch: UnlockedPrivateMessageLocalPatch = {},
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
const localResult = await this.getMessages();
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const localResult = await this._getMessages(ownerKey);
|
||||
if (Result.isErr(localResult)) {
|
||||
throw localResult.error;
|
||||
}
|
||||
@@ -36,46 +56,99 @@ export class ChatLocalMessageStore {
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
const saveResult = await this.saveMessages(updatedMessages);
|
||||
const saveResult = await this._saveMessages(updatedMessages, ownerKey);
|
||||
if (Result.isErr(saveResult)) {
|
||||
throw saveResult.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveMessage(message: ChatMessage): Promise<Result<void>> {
|
||||
return this.localStorage.saveMessage(this._chatToLocal(message));
|
||||
async saveMessage(
|
||||
message: ChatMessage,
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const result = await this.localStorage.saveMessage(
|
||||
this._chatToLocal(message, ownerKey),
|
||||
);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
});
|
||||
}
|
||||
|
||||
async saveMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
const cleared = await this.localStorage.clearAll();
|
||||
if (!cleared.success) {
|
||||
return Result.err(cleared.error);
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const result = await this._saveMessages(messages, ownerKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
});
|
||||
}
|
||||
|
||||
async getMessages(identity?: string): Promise<Result<ChatMessage[]>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const result = await this._getMessages(ownerKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
|
||||
async clearMessages(identity?: string): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const result = await this.localStorage.clearMessagesBySession(ownerKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
});
|
||||
}
|
||||
|
||||
async getMessageCount(identity?: string): Promise<Result<number>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const result = await this.localStorage.getMessageCountBySession(ownerKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
|
||||
private async _requireIdentity(identity?: string): Promise<string> {
|
||||
if (identity !== undefined) {
|
||||
if (identity.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
return this.localStorage.saveMessages(
|
||||
messages.map((message) => this._chatToLocal(message)),
|
||||
const result = await this.resolveIdentity();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
if (result.data.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private async _getMessages(
|
||||
identity: string,
|
||||
): Promise<Result<ChatMessage[]>> {
|
||||
const result = await this.localStorage.getAllMessagesBySession(identity);
|
||||
if (Result.isErr(result)) return result;
|
||||
return Result.ok(
|
||||
result.data.map((message) => this._localToChat(message)),
|
||||
);
|
||||
}
|
||||
|
||||
async getMessages(): Promise<Result<ChatMessage[]>> {
|
||||
const result = await this.localStorage.getAllMessages();
|
||||
if (!result.success) {
|
||||
return Result.err(result.error);
|
||||
}
|
||||
return Result.ok(result.data.map((message) => this._localToChat(message)));
|
||||
private _saveMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
identity: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.localStorage.replaceMessagesBySession(
|
||||
identity,
|
||||
messages.map((message) => this._chatToLocal(message, identity)),
|
||||
);
|
||||
}
|
||||
|
||||
async clearMessages(): Promise<Result<void>> {
|
||||
return this.localStorage.clearAll();
|
||||
}
|
||||
|
||||
async getMessageCount(): Promise<Result<number>> {
|
||||
return this.localStorage.getMessageCount();
|
||||
}
|
||||
|
||||
private _chatToLocal(message: ChatMessage): LocalMessage {
|
||||
private _chatToLocal(message: ChatMessage, identity: string): LocalMessage {
|
||||
return LocalMessage.from({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
@@ -85,7 +158,7 @@ export class ChatLocalMessageStore {
|
||||
audioUrl: message.audioUrl,
|
||||
image: message.image,
|
||||
lockDetail: message.lockDetail,
|
||||
sessionId: "",
|
||||
sessionId: identity,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
LocalChatMediaStorage,
|
||||
type LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage";
|
||||
import { Logger, Result } from "@/utils";
|
||||
import type {
|
||||
@@ -22,54 +20,78 @@ import {
|
||||
isCacheableRemoteChatMediaUrl,
|
||||
uniqueMediaTargets,
|
||||
} from "./chat_media_cache_helpers";
|
||||
import {
|
||||
resolveChatCacheOwnerKey,
|
||||
type ChatCacheIdentityResolver,
|
||||
} from "./chat_cache_identity";
|
||||
|
||||
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
||||
|
||||
type ChatMediaStorage = Pick<
|
||||
LocalChatMediaStorage,
|
||||
"getMedia" | "saveMedia"
|
||||
>;
|
||||
|
||||
export class ChatMediaCacheCoordinator {
|
||||
constructor(private readonly mediaStorage: LocalChatMediaStorage) {}
|
||||
constructor(
|
||||
private readonly mediaStorage: ChatMediaStorage,
|
||||
private readonly resolveIdentity: ChatCacheIdentityResolver =
|
||||
resolveChatCacheOwnerKey,
|
||||
) {}
|
||||
|
||||
async getCachedMedia(
|
||||
input: ChatMediaLookupInput,
|
||||
): Promise<Result<LocalChatMediaRow | null>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
||||
for (const ownerKey of ownerKeys) {
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
if (result.data) return result.data;
|
||||
}
|
||||
return null;
|
||||
const ownerKey = await this._requireIdentity();
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
|
||||
async cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
identity?: string,
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return Result.wrap(() => this._cacheRemoteMediaOrThrow(input));
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
||||
});
|
||||
}
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(
|
||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
||||
return this._prefetchMediaTargets(
|
||||
getSendResponseMediaTargets(response),
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
private async _prefetchMediaTargets(
|
||||
targets: readonly CacheRemoteChatMediaInput[],
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
for (const target of uniqueMediaTargets(targets)) {
|
||||
const uniqueTargets = uniqueMediaTargets(targets);
|
||||
if (uniqueTargets.length === 0) return;
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
for (const target of uniqueTargets) {
|
||||
try {
|
||||
await this._cacheRemoteMediaOrThrow(target);
|
||||
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
||||
} catch (error) {
|
||||
log.warn("[chat-media] prefetch failed", {
|
||||
messageId: target.messageId,
|
||||
@@ -84,20 +106,16 @@ export class ChatMediaCacheCoordinator {
|
||||
|
||||
private async _cacheRemoteMediaOrThrow(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
ownerKey: string,
|
||||
): Promise<LocalChatMediaRow> {
|
||||
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
|
||||
throw new Error(`Unsupported media url: ${input.remoteUrl}`);
|
||||
}
|
||||
|
||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
||||
const ownerKey = ownerKeys[0] ?? "anonymous";
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
for (const lookupOwnerKey of ownerKeys) {
|
||||
const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input);
|
||||
const existingResult = await this.mediaStorage.getMedia(lookupCacheKey);
|
||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||
if (existingResult.data) return existingResult.data;
|
||||
}
|
||||
const existingResult = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||
if (existingResult.data) return existingResult.data;
|
||||
|
||||
const response = await fetch(input.remoteUrl, {
|
||||
method: "GET",
|
||||
@@ -137,28 +155,19 @@ export class ChatMediaCacheCoordinator {
|
||||
return savedResult.data;
|
||||
}
|
||||
|
||||
private async _resolveMediaOwnerKeys(): Promise<string[]> {
|
||||
const ownerKeys: string[] = [];
|
||||
const userIdResult = await UserStorage.getInstance().getUserId();
|
||||
if (
|
||||
Result.isOk(userIdResult) &&
|
||||
userIdResult.data !== null &&
|
||||
userIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`user:${userIdResult.data}`);
|
||||
private async _requireIdentity(identity?: string): Promise<string> {
|
||||
if (identity !== undefined) {
|
||||
if (identity.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
const deviceIdResult = await AuthStorage.getInstance().getDeviceId();
|
||||
if (
|
||||
Result.isOk(deviceIdResult) &&
|
||||
deviceIdResult.data !== null &&
|
||||
deviceIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`device:${deviceIdResult.data}`);
|
||||
const result = await this.resolveIdentity();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
if (result.data.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
|
||||
ownerKeys.push("anonymous");
|
||||
return Array.from(new Set(ownerKeys));
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private _buildMediaCacheKey(
|
||||
|
||||
@@ -68,18 +68,23 @@ export class ChatRepository implements IChatRepository {
|
||||
async markPrivateMessageUnlockedInLocal(
|
||||
messageId: string,
|
||||
patch?: UnlockedPrivateMessageLocalPatch,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.localMessages.markMessageUnlocked(
|
||||
messageId,
|
||||
patch,
|
||||
cacheIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 本地(Dexie)操作 ============
|
||||
|
||||
/** 把一条消息写入本地存储。 */
|
||||
async saveMessageToLocal(message: ChatMessage): Promise<Result<void>> {
|
||||
return this.localMessages.saveMessage(message);
|
||||
async saveMessageToLocal(
|
||||
message: ChatMessage,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.localMessages.saveMessage(message, cacheIdentity);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,26 +93,29 @@ export class ChatRepository implements IChatRepository {
|
||||
*/
|
||||
async saveMessagesToLocal(
|
||||
messages: readonly ChatMessage[],
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.localMessages.saveMessages(messages);
|
||||
return this.localMessages.saveMessages(messages, cacheIdentity);
|
||||
}
|
||||
|
||||
/** 读取所有本地消息,按 dbId 升序。 */
|
||||
async getLocalMessages(): Promise<Result<ChatMessage[]>> {
|
||||
return this.localMessages.getMessages();
|
||||
async getLocalMessages(
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<ChatMessage[]>> {
|
||||
return this.localMessages.getMessages(cacheIdentity);
|
||||
}
|
||||
|
||||
/** 清空本地消息。 */
|
||||
async clearLocalMessages(): Promise<Result<void>> {
|
||||
return this.localMessages.clearMessages();
|
||||
async clearLocalMessages(cacheIdentity?: string): Promise<Result<void>> {
|
||||
return this.localMessages.clearMessages(cacheIdentity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地消息数量。
|
||||
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
||||
*/
|
||||
async getLocalMessageCount(): Promise<Result<number>> {
|
||||
return this.localMessages.getMessageCount();
|
||||
async getLocalMessageCount(cacheIdentity?: string): Promise<Result<number>> {
|
||||
return this.localMessages.getMessageCount(cacheIdentity);
|
||||
}
|
||||
|
||||
// ============ 本地媒体缓存 ============
|
||||
@@ -120,20 +128,26 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
async cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return this.mediaCache.cacheRemoteMedia(input);
|
||||
return this.mediaCache.cacheRemoteMedia(input, cacheIdentity);
|
||||
}
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.mediaCache.prefetchMediaForMessages(messages);
|
||||
return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.mediaCache.prefetchMediaForSendResponse(response);
|
||||
return this.mediaCache.prefetchMediaForSendResponse(
|
||||
response,
|
||||
cacheIdentity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,22 +68,29 @@ export interface IChatRepository {
|
||||
markPrivateMessageUnlockedInLocal(
|
||||
messageId: string,
|
||||
patch?: UnlockedPrivateMessageLocalPatch,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 把一条消息写入本地存储。 */
|
||||
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
|
||||
saveMessageToLocal(
|
||||
message: ChatMessage,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 批量覆盖写入:先清空本地存储,再写入新列表。 */
|
||||
saveMessagesToLocal(messages: readonly ChatMessage[]): Promise<Result<void>>;
|
||||
saveMessagesToLocal(
|
||||
messages: readonly ChatMessage[],
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 读取所有本地消息,按 dbId 升序。 */
|
||||
getLocalMessages(): Promise<Result<ChatMessage[]>>;
|
||||
getLocalMessages(cacheIdentity?: string): Promise<Result<ChatMessage[]>>;
|
||||
|
||||
/** 清空本地消息。 */
|
||||
clearLocalMessages(): Promise<Result<void>>;
|
||||
clearLocalMessages(cacheIdentity?: string): Promise<Result<void>>;
|
||||
|
||||
/** 获取本地消息数量。 */
|
||||
getLocalMessageCount(): Promise<Result<number>>;
|
||||
getLocalMessageCount(cacheIdentity?: string): Promise<Result<number>>;
|
||||
|
||||
/** 获取本地缓存的图片 / 音频。 */
|
||||
getCachedMedia(
|
||||
@@ -93,15 +100,18 @@ export interface IChatRepository {
|
||||
/** 下载并缓存远程图片 / 音频。 */
|
||||
cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<LocalChatMediaRow>>;
|
||||
|
||||
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
||||
prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
||||
prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
/**
|
||||
* Dexie 数据库定义
|
||||
*
|
||||
* 唯一表 `messages`,只有 `++dbId` 自增主键,无次级索引(按本轮需求"最简表")。
|
||||
* 未来要按 session 查询可升级到:
|
||||
* this.version(2).stores({ messages: "++dbId, sessionId, createdAt" })
|
||||
* .upgrade(async (tx) => { ... });
|
||||
* `messages.sessionId` 是当前聊天身份的缓存命名空间。所有消息读写必须通过
|
||||
* 该索引限定身份,不能退化为整表操作。
|
||||
*
|
||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||
*/
|
||||
@@ -63,5 +61,15 @@ export class LocalChatDB extends Dexie {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
/**
|
||||
* LocalChatStorage 完整实现(示范 2:IndexedDB via Dexie)
|
||||
*
|
||||
* 对齐 Dart 端 `LocalChatStorage`(lib/data/services/storage/chat/local_chat_storage.dart):
|
||||
* - init / saveMessage / saveMessages / getAllMessages / clearAll / deleteMessage / close
|
||||
* - 额外提供 `getMessageCount`(异步)替代 Dart 的同步 `messageCount` getter
|
||||
* - 额外提供 `getAllMessagesBySession(sessionId)`(filter 模拟,等下一轮加索引后再用 where)
|
||||
*
|
||||
* 单例挂在 class 静态字段。
|
||||
* Dexie 实例可注入,便于测试用 fake-indexeddb 跑真 Dexie。
|
||||
*
|
||||
* 注意:当前表无次级索引,所有过滤(sessionId)都是 `toArray().then(filter)`,O(n)。
|
||||
* 数据量大时考虑升级 schema 加索引。
|
||||
* 所有读取、替换和删除操作都必须携带 sessionId,避免同一浏览器中的游客、
|
||||
* 登录用户及多个账号共享缓存。
|
||||
*/
|
||||
|
||||
import { Result, type Result as ResultT } from "@/utils";
|
||||
@@ -57,6 +51,7 @@ export class LocalChatStorage {
|
||||
|
||||
async saveMessage(message: LocalMessage): Promise<ResultT<void>> {
|
||||
try {
|
||||
assertSessionId(message.sessionId);
|
||||
await this.db.messages.add(message.toRow());
|
||||
return Result.ok(undefined);
|
||||
} catch (e) {
|
||||
@@ -64,14 +59,24 @@ export class LocalChatStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async saveMessages(messages: readonly LocalMessage[]): Promise<ResultT<void>> {
|
||||
async replaceMessagesBySession(
|
||||
sessionId: string,
|
||||
messages: readonly LocalMessage[],
|
||||
): Promise<ResultT<void>> {
|
||||
try {
|
||||
assertSessionId(sessionId);
|
||||
if (messages.some((message) => message.sessionId !== sessionId)) {
|
||||
throw new Error("Cannot write messages from a different chat identity.");
|
||||
}
|
||||
await this.db.transaction(
|
||||
"rw",
|
||||
this.db.messages,
|
||||
async () => {
|
||||
for (const m of messages) {
|
||||
await this.db.messages.add(m.toRow());
|
||||
await this.db.messages.where("sessionId").equals(sessionId).delete();
|
||||
if (messages.length > 0) {
|
||||
await this.db.messages.bulkAdd(
|
||||
messages.map((message) => message.toRow()),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -83,20 +88,12 @@ export class LocalChatStorage {
|
||||
|
||||
// ---- read ----
|
||||
|
||||
async getAllMessages(): Promise<ResultT<LocalMessage[]>> {
|
||||
async getMessageCountBySession(sessionId: string): Promise<ResultT<number>> {
|
||||
try {
|
||||
const rows = await this.db.messages.toArray();
|
||||
// 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致)
|
||||
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCount(): Promise<ResultT<number>> {
|
||||
try {
|
||||
return Result.ok(await this.db.messages.count());
|
||||
assertSessionId(sessionId);
|
||||
return Result.ok(
|
||||
await this.db.messages.where("sessionId").equals(sessionId).count(),
|
||||
);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
@@ -106,8 +103,10 @@ export class LocalChatStorage {
|
||||
sessionId: string,
|
||||
): Promise<ResultT<LocalMessage[]>> {
|
||||
try {
|
||||
assertSessionId(sessionId);
|
||||
const rows = await this.db.messages
|
||||
.filter((r) => r.sessionId === sessionId)
|
||||
.where("sessionId")
|
||||
.equals(sessionId)
|
||||
.toArray();
|
||||
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
|
||||
@@ -118,9 +117,10 @@ export class LocalChatStorage {
|
||||
|
||||
// ---- delete ----
|
||||
|
||||
async clearAll(): Promise<ResultT<void>> {
|
||||
async clearMessagesBySession(sessionId: string): Promise<ResultT<void>> {
|
||||
try {
|
||||
await this.db.messages.clear();
|
||||
assertSessionId(sessionId);
|
||||
await this.db.messages.where("sessionId").equals(sessionId).delete();
|
||||
return Result.ok(undefined);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
@@ -131,14 +131,21 @@ export class LocalChatStorage {
|
||||
* 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。
|
||||
* O(n) 取下标后单条 delete,与 Dart 行为一致。
|
||||
*/
|
||||
async deleteMessage(index: number): Promise<ResultT<void>> {
|
||||
async deleteMessage(
|
||||
sessionId: string,
|
||||
index: number,
|
||||
): Promise<ResultT<void>> {
|
||||
try {
|
||||
assertSessionId(sessionId);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
return Result.err(
|
||||
new RangeError(`deleteMessage: index ${index} out of range`),
|
||||
);
|
||||
}
|
||||
const rows = await this.db.messages.toArray();
|
||||
const rows = await this.db.messages
|
||||
.where("sessionId")
|
||||
.equals(sessionId)
|
||||
.toArray();
|
||||
if (index >= rows.length) {
|
||||
return Result.err(
|
||||
new RangeError(`deleteMessage: index ${index} out of range`),
|
||||
@@ -161,3 +168,9 @@ export class LocalChatStorage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSessionId(sessionId: string): void {
|
||||
if (sessionId.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user