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>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user