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 { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
|
||||||
import { Result } from "@/utils";
|
import { Result } from "@/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveChatCacheOwnerKey,
|
||||||
|
type ChatCacheIdentityResolver,
|
||||||
|
} from "./chat_cache_identity";
|
||||||
|
|
||||||
|
type LocalMessageStorage = Pick<
|
||||||
|
LocalChatStorage,
|
||||||
|
| "saveMessage"
|
||||||
|
| "replaceMessagesBySession"
|
||||||
|
| "getAllMessagesBySession"
|
||||||
|
| "clearMessagesBySession"
|
||||||
|
| "getMessageCountBySession"
|
||||||
|
>;
|
||||||
|
|
||||||
export class ChatLocalMessageStore {
|
export class ChatLocalMessageStore {
|
||||||
constructor(private readonly localStorage: LocalChatStorage) {}
|
constructor(
|
||||||
|
private readonly localStorage: LocalMessageStorage,
|
||||||
|
private readonly resolveIdentity: ChatCacheIdentityResolver =
|
||||||
|
resolveChatCacheOwnerKey,
|
||||||
|
) {}
|
||||||
|
|
||||||
async markMessageUnlocked(
|
async markMessageUnlocked(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
patch: UnlockedPrivateMessageLocalPatch = {},
|
patch: UnlockedPrivateMessageLocalPatch = {},
|
||||||
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return Result.wrap(async () => {
|
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)) {
|
if (Result.isErr(localResult)) {
|
||||||
throw localResult.error;
|
throw localResult.error;
|
||||||
}
|
}
|
||||||
@@ -36,46 +56,99 @@ export class ChatLocalMessageStore {
|
|||||||
|
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
|
|
||||||
const saveResult = await this.saveMessages(updatedMessages);
|
const saveResult = await this._saveMessages(updatedMessages, ownerKey);
|
||||||
if (Result.isErr(saveResult)) {
|
if (Result.isErr(saveResult)) {
|
||||||
throw saveResult.error;
|
throw saveResult.error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveMessage(message: ChatMessage): Promise<Result<void>> {
|
async saveMessage(
|
||||||
return this.localStorage.saveMessage(this._chatToLocal(message));
|
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(
|
async saveMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
const cleared = await this.localStorage.clearAll();
|
return Result.wrap(async () => {
|
||||||
if (!cleared.success) {
|
const ownerKey = await this._requireIdentity(identity);
|
||||||
return Result.err(cleared.error);
|
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(
|
const result = await this.resolveIdentity();
|
||||||
messages.map((message) => this._chatToLocal(message)),
|
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[]>> {
|
private _saveMessages(
|
||||||
const result = await this.localStorage.getAllMessages();
|
messages: readonly ChatMessage[],
|
||||||
if (!result.success) {
|
identity: string,
|
||||||
return Result.err(result.error);
|
): Promise<Result<void>> {
|
||||||
}
|
return this.localStorage.replaceMessagesBySession(
|
||||||
return Result.ok(result.data.map((message) => this._localToChat(message)));
|
identity,
|
||||||
|
messages.map((message) => this._chatToLocal(message, identity)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearMessages(): Promise<Result<void>> {
|
private _chatToLocal(message: ChatMessage, identity: string): LocalMessage {
|
||||||
return this.localStorage.clearAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessageCount(): Promise<Result<number>> {
|
|
||||||
return this.localStorage.getMessageCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _chatToLocal(message: ChatMessage): LocalMessage {
|
|
||||||
return LocalMessage.from({
|
return LocalMessage.from({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
@@ -85,7 +158,7 @@ export class ChatLocalMessageStore {
|
|||||||
audioUrl: message.audioUrl,
|
audioUrl: message.audioUrl,
|
||||||
image: message.image,
|
image: message.image,
|
||||||
lockDetail: message.lockDetail,
|
lockDetail: message.lockDetail,
|
||||||
sessionId: "",
|
sessionId: identity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
LocalChatMediaStorage,
|
LocalChatMediaStorage,
|
||||||
type LocalChatMediaRow,
|
type LocalChatMediaRow,
|
||||||
} from "@/data/storage/chat";
|
} 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 { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage";
|
||||||
import { Logger, Result } from "@/utils";
|
import { Logger, Result } from "@/utils";
|
||||||
import type {
|
import type {
|
||||||
@@ -22,54 +20,78 @@ import {
|
|||||||
isCacheableRemoteChatMediaUrl,
|
isCacheableRemoteChatMediaUrl,
|
||||||
uniqueMediaTargets,
|
uniqueMediaTargets,
|
||||||
} from "./chat_media_cache_helpers";
|
} from "./chat_media_cache_helpers";
|
||||||
|
import {
|
||||||
|
resolveChatCacheOwnerKey,
|
||||||
|
type ChatCacheIdentityResolver,
|
||||||
|
} from "./chat_cache_identity";
|
||||||
|
|
||||||
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
||||||
|
|
||||||
|
type ChatMediaStorage = Pick<
|
||||||
|
LocalChatMediaStorage,
|
||||||
|
"getMedia" | "saveMedia"
|
||||||
|
>;
|
||||||
|
|
||||||
export class ChatMediaCacheCoordinator {
|
export class ChatMediaCacheCoordinator {
|
||||||
constructor(private readonly mediaStorage: LocalChatMediaStorage) {}
|
constructor(
|
||||||
|
private readonly mediaStorage: ChatMediaStorage,
|
||||||
|
private readonly resolveIdentity: ChatCacheIdentityResolver =
|
||||||
|
resolveChatCacheOwnerKey,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getCachedMedia(
|
async getCachedMedia(
|
||||||
input: ChatMediaLookupInput,
|
input: ChatMediaLookupInput,
|
||||||
): Promise<Result<LocalChatMediaRow | null>> {
|
): Promise<Result<LocalChatMediaRow | null>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
const ownerKey = await this._requireIdentity();
|
||||||
for (const ownerKey of ownerKeys) {
|
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
if (Result.isErr(result)) throw result.error;
|
||||||
if (Result.isErr(result)) throw result.error;
|
return result.data;
|
||||||
if (result.data) return result.data;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async cacheRemoteMedia(
|
async cacheRemoteMedia(
|
||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
|
identity?: string,
|
||||||
): Promise<Result<LocalChatMediaRow>> {
|
): 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(
|
async prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this._prefetchMediaTargets(
|
return this._prefetchMediaTargets(
|
||||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||||
|
identity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForSendResponse(
|
async prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
return this._prefetchMediaTargets(
|
||||||
|
getSendResponseMediaTargets(response),
|
||||||
|
identity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _prefetchMediaTargets(
|
private async _prefetchMediaTargets(
|
||||||
targets: readonly CacheRemoteChatMediaInput[],
|
targets: readonly CacheRemoteChatMediaInput[],
|
||||||
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return Result.wrap(async () => {
|
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 {
|
try {
|
||||||
await this._cacheRemoteMediaOrThrow(target);
|
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("[chat-media] prefetch failed", {
|
log.warn("[chat-media] prefetch failed", {
|
||||||
messageId: target.messageId,
|
messageId: target.messageId,
|
||||||
@@ -84,20 +106,16 @@ export class ChatMediaCacheCoordinator {
|
|||||||
|
|
||||||
private async _cacheRemoteMediaOrThrow(
|
private async _cacheRemoteMediaOrThrow(
|
||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
|
ownerKey: string,
|
||||||
): Promise<LocalChatMediaRow> {
|
): Promise<LocalChatMediaRow> {
|
||||||
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
|
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
|
||||||
throw new Error(`Unsupported media url: ${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);
|
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||||
for (const lookupOwnerKey of ownerKeys) {
|
const existingResult = await this.mediaStorage.getMedia(cacheKey);
|
||||||
const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input);
|
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||||
const existingResult = await this.mediaStorage.getMedia(lookupCacheKey);
|
if (existingResult.data) return existingResult.data;
|
||||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
|
||||||
if (existingResult.data) return existingResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(input.remoteUrl, {
|
const response = await fetch(input.remoteUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -137,28 +155,19 @@ export class ChatMediaCacheCoordinator {
|
|||||||
return savedResult.data;
|
return savedResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _resolveMediaOwnerKeys(): Promise<string[]> {
|
private async _requireIdentity(identity?: string): Promise<string> {
|
||||||
const ownerKeys: string[] = [];
|
if (identity !== undefined) {
|
||||||
const userIdResult = await UserStorage.getInstance().getUserId();
|
if (identity.length === 0) {
|
||||||
if (
|
throw new Error("Chat cache identity must not be empty.");
|
||||||
Result.isOk(userIdResult) &&
|
}
|
||||||
userIdResult.data !== null &&
|
return identity;
|
||||||
userIdResult.data.length > 0
|
|
||||||
) {
|
|
||||||
ownerKeys.push(`user:${userIdResult.data}`);
|
|
||||||
}
|
}
|
||||||
|
const result = await this.resolveIdentity();
|
||||||
const deviceIdResult = await AuthStorage.getInstance().getDeviceId();
|
if (Result.isErr(result)) throw result.error;
|
||||||
if (
|
if (result.data.length === 0) {
|
||||||
Result.isOk(deviceIdResult) &&
|
throw new Error("Chat cache identity must not be empty.");
|
||||||
deviceIdResult.data !== null &&
|
|
||||||
deviceIdResult.data.length > 0
|
|
||||||
) {
|
|
||||||
ownerKeys.push(`device:${deviceIdResult.data}`);
|
|
||||||
}
|
}
|
||||||
|
return result.data;
|
||||||
ownerKeys.push("anonymous");
|
|
||||||
return Array.from(new Set(ownerKeys));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildMediaCacheKey(
|
private _buildMediaCacheKey(
|
||||||
|
|||||||
@@ -68,18 +68,23 @@ export class ChatRepository implements IChatRepository {
|
|||||||
async markPrivateMessageUnlockedInLocal(
|
async markPrivateMessageUnlockedInLocal(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
patch?: UnlockedPrivateMessageLocalPatch,
|
patch?: UnlockedPrivateMessageLocalPatch,
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.localMessages.markMessageUnlocked(
|
return this.localMessages.markMessageUnlocked(
|
||||||
messageId,
|
messageId,
|
||||||
patch,
|
patch,
|
||||||
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 本地(Dexie)操作 ============
|
// ============ 本地(Dexie)操作 ============
|
||||||
|
|
||||||
/** 把一条消息写入本地存储。 */
|
/** 把一条消息写入本地存储。 */
|
||||||
async saveMessageToLocal(message: ChatMessage): Promise<Result<void>> {
|
async saveMessageToLocal(
|
||||||
return this.localMessages.saveMessage(message);
|
message: ChatMessage,
|
||||||
|
cacheIdentity?: string,
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
return this.localMessages.saveMessage(message, cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,26 +93,29 @@ export class ChatRepository implements IChatRepository {
|
|||||||
*/
|
*/
|
||||||
async saveMessagesToLocal(
|
async saveMessagesToLocal(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.localMessages.saveMessages(messages);
|
return this.localMessages.saveMessages(messages, cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取所有本地消息,按 dbId 升序。 */
|
/** 读取所有本地消息,按 dbId 升序。 */
|
||||||
async getLocalMessages(): Promise<Result<ChatMessage[]>> {
|
async getLocalMessages(
|
||||||
return this.localMessages.getMessages();
|
cacheIdentity?: string,
|
||||||
|
): Promise<Result<ChatMessage[]>> {
|
||||||
|
return this.localMessages.getMessages(cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空本地消息。 */
|
/** 清空本地消息。 */
|
||||||
async clearLocalMessages(): Promise<Result<void>> {
|
async clearLocalMessages(cacheIdentity?: string): Promise<Result<void>> {
|
||||||
return this.localMessages.clearMessages();
|
return this.localMessages.clearMessages(cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取本地消息数量。
|
* 获取本地消息数量。
|
||||||
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
||||||
*/
|
*/
|
||||||
async getLocalMessageCount(): Promise<Result<number>> {
|
async getLocalMessageCount(cacheIdentity?: string): Promise<Result<number>> {
|
||||||
return this.localMessages.getMessageCount();
|
return this.localMessages.getMessageCount(cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 本地媒体缓存 ============
|
// ============ 本地媒体缓存 ============
|
||||||
@@ -120,20 +128,26 @@ export class ChatRepository implements IChatRepository {
|
|||||||
|
|
||||||
async cacheRemoteMedia(
|
async cacheRemoteMedia(
|
||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<LocalChatMediaRow>> {
|
): Promise<Result<LocalChatMediaRow>> {
|
||||||
return this.mediaCache.cacheRemoteMedia(input);
|
return this.mediaCache.cacheRemoteMedia(input, cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForMessages(
|
async prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.mediaCache.prefetchMediaForMessages(messages);
|
return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForSendResponse(
|
async prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.mediaCache.prefetchMediaForSendResponse(response);
|
return this.mediaCache.prefetchMediaForSendResponse(
|
||||||
|
response,
|
||||||
|
cacheIdentity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,22 +68,29 @@ export interface IChatRepository {
|
|||||||
markPrivateMessageUnlockedInLocal(
|
markPrivateMessageUnlockedInLocal(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
patch?: UnlockedPrivateMessageLocalPatch,
|
patch?: UnlockedPrivateMessageLocalPatch,
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>>;
|
): 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 升序。 */
|
/** 读取所有本地消息,按 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(
|
getCachedMedia(
|
||||||
@@ -93,15 +100,18 @@ export interface IChatRepository {
|
|||||||
/** 下载并缓存远程图片 / 音频。 */
|
/** 下载并缓存远程图片 / 音频。 */
|
||||||
cacheRemoteMedia(
|
cacheRemoteMedia(
|
||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<LocalChatMediaRow>>;
|
): Promise<Result<LocalChatMediaRow>>;
|
||||||
|
|
||||||
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
||||||
prefetchMediaForMessages(
|
prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>>;
|
): Promise<Result<void>>;
|
||||||
|
|
||||||
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
||||||
prefetchMediaForSendResponse(
|
prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>>;
|
): Promise<Result<void>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Dexie 数据库定义
|
* Dexie 数据库定义
|
||||||
*
|
*
|
||||||
* 唯一表 `messages`,只有 `++dbId` 自增主键,无次级索引(按本轮需求"最简表")。
|
* `messages.sessionId` 是当前聊天身份的缓存命名空间。所有消息读写必须通过
|
||||||
* 未来要按 session 查询可升级到:
|
* 该索引限定身份,不能退化为整表操作。
|
||||||
* this.version(2).stores({ messages: "++dbId, sessionId, createdAt" })
|
|
||||||
* .upgrade(async (tx) => { ... });
|
|
||||||
*
|
*
|
||||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||||
*/
|
*/
|
||||||
@@ -63,5 +61,15 @@ export class LocalChatDB extends Dexie {
|
|||||||
messages: "++dbId",
|
messages: "++dbId",
|
||||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
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)
|
* 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。
|
* Dexie 实例可注入,便于测试用 fake-indexeddb 跑真 Dexie。
|
||||||
*
|
*
|
||||||
* 注意:当前表无次级索引,所有过滤(sessionId)都是 `toArray().then(filter)`,O(n)。
|
* 所有读取、替换和删除操作都必须携带 sessionId,避免同一浏览器中的游客、
|
||||||
* 数据量大时考虑升级 schema 加索引。
|
* 登录用户及多个账号共享缓存。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Result, type Result as ResultT } from "@/utils";
|
import { Result, type Result as ResultT } from "@/utils";
|
||||||
@@ -57,6 +51,7 @@ export class LocalChatStorage {
|
|||||||
|
|
||||||
async saveMessage(message: LocalMessage): Promise<ResultT<void>> {
|
async saveMessage(message: LocalMessage): Promise<ResultT<void>> {
|
||||||
try {
|
try {
|
||||||
|
assertSessionId(message.sessionId);
|
||||||
await this.db.messages.add(message.toRow());
|
await this.db.messages.add(message.toRow());
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} 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 {
|
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(
|
await this.db.transaction(
|
||||||
"rw",
|
"rw",
|
||||||
this.db.messages,
|
this.db.messages,
|
||||||
async () => {
|
async () => {
|
||||||
for (const m of messages) {
|
await this.db.messages.where("sessionId").equals(sessionId).delete();
|
||||||
await this.db.messages.add(m.toRow());
|
if (messages.length > 0) {
|
||||||
|
await this.db.messages.bulkAdd(
|
||||||
|
messages.map((message) => message.toRow()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -83,20 +88,12 @@ export class LocalChatStorage {
|
|||||||
|
|
||||||
// ---- read ----
|
// ---- read ----
|
||||||
|
|
||||||
async getAllMessages(): Promise<ResultT<LocalMessage[]>> {
|
async getMessageCountBySession(sessionId: string): Promise<ResultT<number>> {
|
||||||
try {
|
try {
|
||||||
const rows = await this.db.messages.toArray();
|
assertSessionId(sessionId);
|
||||||
// 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致)
|
return Result.ok(
|
||||||
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
await this.db.messages.where("sessionId").equals(sessionId).count(),
|
||||||
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());
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(e);
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
@@ -106,8 +103,10 @@ export class LocalChatStorage {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<ResultT<LocalMessage[]>> {
|
): Promise<ResultT<LocalMessage[]>> {
|
||||||
try {
|
try {
|
||||||
|
assertSessionId(sessionId);
|
||||||
const rows = await this.db.messages
|
const rows = await this.db.messages
|
||||||
.filter((r) => r.sessionId === sessionId)
|
.where("sessionId")
|
||||||
|
.equals(sessionId)
|
||||||
.toArray();
|
.toArray();
|
||||||
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||||
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
|
return Result.ok(rows.map((r) => LocalMessage.fromRow(r)));
|
||||||
@@ -118,9 +117,10 @@ export class LocalChatStorage {
|
|||||||
|
|
||||||
// ---- delete ----
|
// ---- delete ----
|
||||||
|
|
||||||
async clearAll(): Promise<ResultT<void>> {
|
async clearMessagesBySession(sessionId: string): Promise<ResultT<void>> {
|
||||||
try {
|
try {
|
||||||
await this.db.messages.clear();
|
assertSessionId(sessionId);
|
||||||
|
await this.db.messages.where("sessionId").equals(sessionId).delete();
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(e);
|
return Result.err(e);
|
||||||
@@ -131,14 +131,21 @@ export class LocalChatStorage {
|
|||||||
* 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。
|
* 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。
|
||||||
* O(n) 取下标后单条 delete,与 Dart 行为一致。
|
* O(n) 取下标后单条 delete,与 Dart 行为一致。
|
||||||
*/
|
*/
|
||||||
async deleteMessage(index: number): Promise<ResultT<void>> {
|
async deleteMessage(
|
||||||
|
sessionId: string,
|
||||||
|
index: number,
|
||||||
|
): Promise<ResultT<void>> {
|
||||||
try {
|
try {
|
||||||
|
assertSessionId(sessionId);
|
||||||
if (!Number.isInteger(index) || index < 0) {
|
if (!Number.isInteger(index) || index < 0) {
|
||||||
return Result.err(
|
return Result.err(
|
||||||
new RangeError(`deleteMessage: index ${index} out of range`),
|
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) {
|
if (index >= rows.length) {
|
||||||
return Result.err(
|
return Result.err(
|
||||||
new RangeError(`deleteMessage: index ${index} out of range`),
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { fromCallback, fromPromise } from "xstate";
|
import { fromCallback, fromPromise } from "xstate";
|
||||||
|
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
|
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger, Result } from "@/utils";
|
import { Logger, Result } from "@/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
readLocalHistorySnapshot,
|
readLocalHistorySnapshot,
|
||||||
|
resolveHistoryCacheIdentity,
|
||||||
syncNetworkHistory,
|
syncNetworkHistory,
|
||||||
} from "./chat-history-sync";
|
} from "./chat-history-sync";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +23,8 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const localSnapshot = await readLocalHistorySnapshot();
|
const cacheIdentity = await resolveHistoryCacheIdentity();
|
||||||
|
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
sendBack({
|
sendBack({
|
||||||
type: "ChatLocalHistoryLoaded",
|
type: "ChatLocalHistoryLoaded",
|
||||||
@@ -30,6 +33,7 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
|||||||
|
|
||||||
const networkSnapshot = await syncNetworkHistory(
|
const networkSnapshot = await syncNetworkHistory(
|
||||||
localSnapshot.localCount,
|
localSnapshot.localCount,
|
||||||
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
if (cancelled || !networkSnapshot) return;
|
if (cancelled || !networkSnapshot) return;
|
||||||
sendBack({
|
sendBack({
|
||||||
@@ -52,6 +56,7 @@ export const loadMoreHistoryActor = fromPromise<
|
|||||||
{ offset: number }
|
{ offset: number }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const chatRepo = getChatRepository();
|
const chatRepo = getChatRepository();
|
||||||
|
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||||
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
|
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
log.error("[chat-machine] loadMoreHistoryActor failed", {
|
log.error("[chat-machine] loadMoreHistoryActor failed", {
|
||||||
@@ -60,7 +65,12 @@ export const loadMoreHistoryActor = fromPromise<
|
|||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
const page = localMessagesToUi(result.data.messages);
|
const page = localMessagesToUi(result.data.messages);
|
||||||
void chatRepo.prefetchMediaForMessages(result.data.messages);
|
if (Result.isOk(cacheIdentityResult)) {
|
||||||
|
void chatRepo.prefetchMediaForMessages(
|
||||||
|
result.data.messages,
|
||||||
|
cacheIdentityResult.data,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
messages: page,
|
messages: page,
|
||||||
hasMore: page.length >= PAGE_SIZE,
|
hasMore: page.length >= PAGE_SIZE,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { UiMessage } from "@/data/dto/chat";
|
import type { UiMessage } from "@/data/dto/chat";
|
||||||
|
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
import { Logger, Result, todayString } from "@/utils";
|
import { Logger, Result, todayString } from "@/utils";
|
||||||
|
|
||||||
@@ -38,13 +39,22 @@ export function createGreetingMessage(): UiMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readLocalHistorySnapshot(): Promise<LocalHistorySnapshotOutput> {
|
export async function resolveHistoryCacheIdentity(): Promise<string | null> {
|
||||||
|
const result = await resolveChatCacheOwnerKey();
|
||||||
|
return Result.isOk(result) ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLocalHistorySnapshot(
|
||||||
|
cacheIdentity: string | null,
|
||||||
|
): Promise<LocalHistorySnapshotOutput> {
|
||||||
const chatRepo = getChatRepository();
|
const chatRepo = getChatRepository();
|
||||||
const greetingMessage = createGreetingMessage();
|
const greetingMessage = createGreetingMessage();
|
||||||
|
|
||||||
const localResult = await chatRepo.getLocalMessages();
|
const localResult = cacheIdentity
|
||||||
|
? await chatRepo.getLocalMessages(cacheIdentity)
|
||||||
|
: null;
|
||||||
const localMessages =
|
const localMessages =
|
||||||
Result.isOk(localResult) && localResult.data
|
localResult && Result.isOk(localResult) && localResult.data
|
||||||
? localMessagesToUi(localResult.data)
|
? localMessagesToUi(localResult.data)
|
||||||
: [];
|
: [];
|
||||||
log.debug("[chat-machine] loadHistory LOCAL DONE", {
|
log.debug("[chat-machine] loadHistory LOCAL DONE", {
|
||||||
@@ -67,6 +77,7 @@ export async function readLocalHistorySnapshot(): Promise<LocalHistorySnapshotOu
|
|||||||
|
|
||||||
export async function syncNetworkHistory(
|
export async function syncNetworkHistory(
|
||||||
localCount: number,
|
localCount: number,
|
||||||
|
cacheIdentity: string | null,
|
||||||
): Promise<NetworkHistorySyncOutput | null> {
|
): Promise<NetworkHistorySyncOutput | null> {
|
||||||
const chatRepo = getChatRepository();
|
const chatRepo = getChatRepository();
|
||||||
const greetingMessage = createGreetingMessage();
|
const greetingMessage = createGreetingMessage();
|
||||||
@@ -83,12 +94,20 @@ export async function syncNetworkHistory(
|
|||||||
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
||||||
count: networkUi.length,
|
count: networkUi.length,
|
||||||
});
|
});
|
||||||
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
|
if (cacheIdentity) {
|
||||||
|
void chatRepo.prefetchMediaForMessages(
|
||||||
|
networkResult.data.messages,
|
||||||
|
cacheIdentity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const saveResult = await chatRepo.saveMessagesToLocal(
|
const saveResult = cacheIdentity
|
||||||
networkResult.data.messages,
|
? await chatRepo.saveMessagesToLocal(
|
||||||
);
|
networkResult.data.messages,
|
||||||
const localOverwritten = Result.isOk(saveResult);
|
cacheIdentity,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const localOverwritten = saveResult !== null && Result.isOk(saveResult);
|
||||||
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
|
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
|
||||||
localOverwritten,
|
localOverwritten,
|
||||||
});
|
});
|
||||||
@@ -116,8 +135,12 @@ export async function syncNetworkHistory(
|
|||||||
* 3. Overwrite local history with network data.
|
* 3. Overwrite local history with network data.
|
||||||
*/
|
*/
|
||||||
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
||||||
const localSnapshot = await readLocalHistorySnapshot();
|
const cacheIdentity = await resolveHistoryCacheIdentity();
|
||||||
const networkSnapshot = await syncNetworkHistory(localSnapshot.localCount);
|
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||||
|
const networkSnapshot = await syncNetworkHistory(
|
||||||
|
localSnapshot.localCount,
|
||||||
|
cacheIdentity,
|
||||||
|
);
|
||||||
if (networkSnapshot) return networkSnapshot;
|
if (networkSnapshot) return networkSnapshot;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ExceptionHandler } from "@/core/errors";
|
|||||||
import { MessageQueue } from "@/core/net/message-queue";
|
import { MessageQueue } from "@/core/net/message-queue";
|
||||||
import type { ChatSendResponse } from "@/data/dto/chat";
|
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
|
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger, Result, todayString } from "@/utils";
|
import { Logger, Result, todayString } from "@/utils";
|
||||||
|
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
@@ -162,6 +163,7 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
}> {
|
}> {
|
||||||
const chatRepo = getChatRepository();
|
const chatRepo = getChatRepository();
|
||||||
|
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||||
const result = await chatRepo.sendMessage(content);
|
const result = await chatRepo.sendMessage(content);
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||||
@@ -169,7 +171,12 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
});
|
});
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
void chatRepo.prefetchMediaForSendResponse(result.data);
|
if (Result.isOk(cacheIdentityResult)) {
|
||||||
|
void chatRepo.prefetchMediaForSendResponse(
|
||||||
|
result.data,
|
||||||
|
cacheIdentityResult.data,
|
||||||
|
);
|
||||||
|
}
|
||||||
const isInsufficientCredits =
|
const isInsufficientCredits =
|
||||||
result.data.canSendMessage === false &&
|
result.data.canSendMessage === false &&
|
||||||
!hasRenderableSendResponse(result.data);
|
!hasRenderableSendResponse(result.data);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fromPromise } from "xstate";
|
import { fromPromise } from "xstate";
|
||||||
|
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
|
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger, Result } from "@/utils";
|
import { Logger, Result } from "@/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +55,7 @@ export const unlockMessageActor = fromPromise<
|
|||||||
UnlockMessageRequest
|
UnlockMessageRequest
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const chatRepo = getChatRepository();
|
const chatRepo = getChatRepository();
|
||||||
|
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||||
@@ -68,7 +70,12 @@ export const unlockMessageActor = fromPromise<
|
|||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) {
|
if (
|
||||||
|
unlockResult.data.unlocked &&
|
||||||
|
input.messageId &&
|
||||||
|
!input.clientLockId &&
|
||||||
|
Result.isOk(cacheIdentityResult)
|
||||||
|
) {
|
||||||
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||||
input.messageId,
|
input.messageId,
|
||||||
{
|
{
|
||||||
@@ -79,6 +86,7 @@ export const unlockMessageActor = fromPromise<
|
|||||||
: {}),
|
: {}),
|
||||||
lockDetail: unlockResult.data.lockDetail,
|
lockDetail: unlockResult.data.lockDetail,
|
||||||
},
|
},
|
||||||
|
cacheIdentityResult.data,
|
||||||
);
|
);
|
||||||
if (Result.isErr(markResult)) {
|
if (Result.isErr(markResult)) {
|
||||||
log.warn("[chat-machine] mark unlocked local message failed", {
|
log.warn("[chat-machine] mark unlocked local message failed", {
|
||||||
|
|||||||
Reference in New Issue
Block a user