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:
2026-07-13 16:54:34 +08:00
parent 7c69b6cd8a
commit b126b83c4c
14 changed files with 601 additions and 133 deletions
+12 -2
View File
@@ -1,10 +1,12 @@
import { fromCallback, fromPromise } from "xstate";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { Logger, Result } from "@/utils";
import {
readLocalHistorySnapshot,
resolveHistoryCacheIdentity,
syncNetworkHistory,
} from "./chat-history-sync";
import {
@@ -21,7 +23,8 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
let cancelled = false;
void (async () => {
const localSnapshot = await readLocalHistorySnapshot();
const cacheIdentity = await resolveHistoryCacheIdentity();
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
if (cancelled) return;
sendBack({
type: "ChatLocalHistoryLoaded",
@@ -30,6 +33,7 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
const networkSnapshot = await syncNetworkHistory(
localSnapshot.localCount,
cacheIdentity,
);
if (cancelled || !networkSnapshot) return;
sendBack({
@@ -52,6 +56,7 @@ export const loadMoreHistoryActor = fromPromise<
{ offset: number }
>(async ({ input }) => {
const chatRepo = getChatRepository();
const cacheIdentityResult = await resolveChatCacheOwnerKey();
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
if (Result.isErr(result)) {
log.error("[chat-machine] loadMoreHistoryActor failed", {
@@ -60,7 +65,12 @@ export const loadMoreHistoryActor = fromPromise<
throw result.error;
}
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 {
messages: page,
hasMore: page.length >= PAGE_SIZE,
+33 -10
View File
@@ -1,4 +1,5 @@
import type { UiMessage } from "@/data/dto/chat";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { getChatRepository } from "@/data/repositories/chat_repository";
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 greetingMessage = createGreetingMessage();
const localResult = await chatRepo.getLocalMessages();
const localResult = cacheIdentity
? await chatRepo.getLocalMessages(cacheIdentity)
: null;
const localMessages =
Result.isOk(localResult) && localResult.data
localResult && Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [];
log.debug("[chat-machine] loadHistory LOCAL DONE", {
@@ -67,6 +77,7 @@ export async function readLocalHistorySnapshot(): Promise<LocalHistorySnapshotOu
export async function syncNetworkHistory(
localCount: number,
cacheIdentity: string | null,
): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = getChatRepository();
const greetingMessage = createGreetingMessage();
@@ -83,12 +94,20 @@ export async function syncNetworkHistory(
log.debug("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
if (cacheIdentity) {
void chatRepo.prefetchMediaForMessages(
networkResult.data.messages,
cacheIdentity,
);
}
const saveResult = await chatRepo.saveMessagesToLocal(
networkResult.data.messages,
);
const localOverwritten = Result.isOk(saveResult);
const saveResult = cacheIdentity
? await chatRepo.saveMessagesToLocal(
networkResult.data.messages,
cacheIdentity,
)
: null;
const localOverwritten = saveResult !== null && Result.isOk(saveResult);
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
localOverwritten,
});
@@ -116,8 +135,12 @@ export async function syncNetworkHistory(
* 3. Overwrite local history with network data.
*/
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
const localSnapshot = await readLocalHistorySnapshot();
const networkSnapshot = await syncNetworkHistory(localSnapshot.localCount);
const cacheIdentity = await resolveHistoryCacheIdentity();
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
const networkSnapshot = await syncNetworkHistory(
localSnapshot.localCount,
cacheIdentity,
);
if (networkSnapshot) return networkSnapshot;
return {
+8 -1
View File
@@ -4,6 +4,7 @@ import { ExceptionHandler } from "@/core/errors";
import { MessageQueue } from "@/core/net/message-queue";
import type { ChatSendResponse } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { Logger, Result, todayString } from "@/utils";
import type { ChatEvent } from "./chat-events";
@@ -162,6 +163,7 @@ async function sendMessageViaHttp(content: string): Promise<{
reply: UiMessage | null;
}> {
const chatRepo = getChatRepository();
const cacheIdentityResult = await resolveChatCacheOwnerKey();
const result = await chatRepo.sendMessage(content);
if (Result.isErr(result)) {
log.error("[chat-machine] sendMessageHttpActor failed", {
@@ -169,7 +171,12 @@ async function sendMessageViaHttp(content: string): Promise<{
});
throw result.error;
}
void chatRepo.prefetchMediaForSendResponse(result.data);
if (Result.isOk(cacheIdentityResult)) {
void chatRepo.prefetchMediaForSendResponse(
result.data,
cacheIdentityResult.data,
);
}
const isInsufficientCredits =
result.data.canSendMessage === false &&
!hasRenderableSendResponse(result.data);
+9 -1
View File
@@ -1,6 +1,7 @@
import { fromPromise } from "xstate";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { Logger, Result } from "@/utils";
import {
@@ -54,6 +55,7 @@ export const unlockMessageActor = fromPromise<
UnlockMessageRequest
>(async ({ input }) => {
const chatRepo = getChatRepository();
const cacheIdentityResult = await resolveChatCacheOwnerKey();
const unlockResult = await chatRepo.unlockPrivateMessage({
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.lockType ? { lockType: input.lockType } : {}),
@@ -68,7 +70,12 @@ export const unlockMessageActor = fromPromise<
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(
input.messageId,
{
@@ -79,6 +86,7 @@ export const unlockMessageActor = fromPromise<
: {}),
lockDetail: unlockResult.data.lockDetail,
},
cacheIdentityResult.data,
);
if (Result.isErr(markResult)) {
log.warn("[chat-machine] mark unlocked local message failed", {