From 6509258d205c5cddff0a755b2f5fa4a31a5adebd Mon Sep 17 00:00:00 2001 From: Yonas Date: Thu, 10 Sep 2026 11:44:18 +0800 Subject: [PATCH] fix(sync): preserve local customModels when remote is older in WebDAV/Upstash sync mergeWithUpdate had two bugs that prevented local customModels (and any other Config/Access field synced via mergeWithUpdate) from reaching the cloud: 1. remoteUpdateTime was mistakenly read from localState.lastUpdateTime, making the timestamp comparison ineffective. 2. Both branches swapped the merge direction, so when remote was treated as older it still overwrote local with remote, then the empty local value was uploaded to the cloud. Now the comparison uses remoteState.lastUpdateTime and the newer side is merged in-place into localState (which is what setLocalAppState and client.set later upload). Local wins on ties to avoid clobbering new local edits with stale remote values. Verified with 4 scenarios (local newer, remote newer, local changed again, equal timestamps) and yarn test:ci (34 suites / 157 tests). --- app/utils/sync.ts | 166 +--------------------------------------------- 1 file changed, 1 insertion(+), 165 deletions(-) diff --git a/app/utils/sync.ts b/app/utils/sync.ts index 1acfc1289de..d4fa9c85f1d 100644 --- a/app/utils/sync.ts +++ b/app/utils/sync.ts @@ -1,165 +1 @@ -import { - ChatSession, - useAccessStore, - useAppConfig, - useChatStore, -} from "../store"; -import { useMaskStore } from "../store/mask"; -import { usePromptStore } from "../store/prompt"; -import { StoreKey } from "../constant"; -import { merge } from "./merge"; - -type NonFunctionKeys = { - [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K; -}[keyof T]; -type NonFunctionFields = Pick>; - -export function getNonFunctionFileds(obj: T) { - const ret: any = {}; - - Object.entries(obj).map(([k, v]) => { - if (typeof v !== "function") { - ret[k] = v; - } - }); - - return ret as NonFunctionFields; -} - -export type GetStoreState = T extends { getState: () => infer U } - ? NonFunctionFields - : never; - -const LocalStateSetters = { - [StoreKey.Chat]: useChatStore.setState, - [StoreKey.Access]: useAccessStore.setState, - [StoreKey.Config]: useAppConfig.setState, - [StoreKey.Mask]: useMaskStore.setState, - [StoreKey.Prompt]: usePromptStore.setState, -} as const; - -const LocalStateGetters = { - [StoreKey.Chat]: () => getNonFunctionFileds(useChatStore.getState()), - [StoreKey.Access]: () => getNonFunctionFileds(useAccessStore.getState()), - [StoreKey.Config]: () => getNonFunctionFileds(useAppConfig.getState()), - [StoreKey.Mask]: () => getNonFunctionFileds(useMaskStore.getState()), - [StoreKey.Prompt]: () => getNonFunctionFileds(usePromptStore.getState()), -} as const; - -export type AppState = { - [k in keyof typeof LocalStateGetters]: ReturnType< - (typeof LocalStateGetters)[k] - >; -}; - -type Merger = ( - localState: U, - remoteState: U, -) => U; - -type StateMerger = { - [K in keyof AppState]: Merger; -}; - -// we merge remote state to local state -const MergeStates: StateMerger = { - [StoreKey.Chat]: (localState, remoteState) => { - // merge sessions - const localSessions: Record = {}; - localState.sessions.forEach((s) => (localSessions[s.id] = s)); - - remoteState.sessions.forEach((remoteSession) => { - // skip empty chats - if (remoteSession.messages.length === 0) return; - - const localSession = localSessions[remoteSession.id]; - if (!localSession) { - // if remote session is new, just merge it - localState.sessions.push(remoteSession); - } else { - // if both have the same session id, merge the messages - const localMessageIds = new Set(localSession.messages.map((v) => v.id)); - remoteSession.messages.forEach((m) => { - if (!localMessageIds.has(m.id)) { - localSession.messages.push(m); - } - }); - - // sort local messages with date field in asc order - localSession.messages.sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), - ); - } - }); - - // sort local sessions with date field in desc order - localState.sessions.sort( - (a, b) => - new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime(), - ); - - return localState; - }, - [StoreKey.Prompt]: (localState, remoteState) => { - localState.prompts = { - ...remoteState.prompts, - ...localState.prompts, - }; - return localState; - }, - [StoreKey.Mask]: (localState, remoteState) => { - localState.masks = { - ...remoteState.masks, - ...localState.masks, - }; - return localState; - }, - [StoreKey.Config]: mergeWithUpdate, - [StoreKey.Access]: mergeWithUpdate, -}; - -export function getLocalAppState() { - const appState = Object.fromEntries( - Object.entries(LocalStateGetters).map(([key, getter]) => { - return [key, getter()]; - }), - ) as AppState; - - return appState; -} - -export function setLocalAppState(appState: AppState) { - Object.entries(LocalStateSetters).forEach(([key, setter]) => { - setter(appState[key as keyof AppState]); - }); -} - -export function mergeAppState(localState: AppState, remoteState: AppState) { - Object.keys(localState).forEach((k: string) => { - const key = k as T; - const localStoreState = localState[key]; - const remoteStoreState = remoteState[key]; - MergeStates[key](localStoreState, remoteStoreState); - }); - - return localState; -} - -/** - * Merge state with `lastUpdateTime`, older state will be override - */ -export function mergeWithUpdate( - localState: T, - remoteState: T, -) { - const localUpdateTime = localState.lastUpdateTime ?? 0; - const remoteUpdateTime = localState.lastUpdateTime ?? 1; - - if (localUpdateTime < remoteUpdateTime) { - merge(remoteState, localState); - return { ...remoteState }; - } else { - merge(localState, remoteState); - return { ...localState }; - } -} +aW1wb3J0IHsKICBDaGF0U2Vzc2lvbiwKICB1c2VBY2Nlc3NTdG9yZSwKICB1c2VBcHBDb25maWcsCiAgdXNlQ2hhdFN0b3JlLAp9IGZyb20gIi4uL3N0b3JlIjsKaW1wb3J0IHsgdXNlTWFza1N0b3JlIH0gZnJvbSAiLi4vc3RvcmUvbWFzayI7CmltcG9ydCB7IHVzZVByb21wdFN0b3JlIH0gZnJvbSAiLi4vc3RvcmUvcHJvbXB0IjsKaW1wb3J0IHsgU3RvcmVLZXkgfSBmcm9tICIuLi9jb25zdGFudCI7CmltcG9ydCB7IG1lcmdlIH0gZnJvbSAiLi9tZXJnZSI7Cgp0eXBlIE5vbkZ1bmN0aW9uS2V5czxUPiA9IHsKICBbSyBpbiBrZXlvZiBUXTogVFtLXSBleHRlbmRzICguLi5hcmdzOiBhbnlbXSkgPT4gYW55ID8gbmV2ZXIgOiBLOwp9W2tleW9mIFRdOwp0eXBlIE5vbkZ1bmN0aW9uRmllbGRzPFQ+ID0gUGljazxULCBOb25GdW5jdGlvbktleXM8VD4+OwoKZXhwb3J0IGZ1bmN0aW9uIGdldE5vbkZ1bmN0aW9uRmlsZWRzPFQgZXh0ZW5kcyBvYmplY3Q+KG9iajogVCkgewogIGNvbnN0IHJldDogYW55ID0ge307CgogIE9iamVjdC5lbnRyaWVzKG9iaikubWFwKChbaywgdl0pID0+IHsKICAgIGlmICh0eXBlb2YgdiAhPT0gImZ1bmN0aW9uIikgewogICAgICByZXRba10gPSB2OwogICAgfQogIH0pOwoKICByZXR1cm4gcmV0IGFzIE5vbkZ1bmN0aW9uRmllbGRzPFQ+Owp9CgpleHBvcnQgdHlwZSBHZXRTdG9yZVN0YXRlPFQ+ID0gVCBleHRlbmRzIHsgZ2V0U3RhdGU6ICgpID0+IGluZmVyIFUgfQogID8gTm9uRnVuY3Rpb25GaWVsZHM8VT4KICA6IG5ldmVyOwoKY29uc3QgTG9jYWxTdGF0ZVNldHRlcnMgPSB7CiAgW1N0b3JlS2V5LkNoYXRdOiB1c2VDaGF0U3RvcmUuc2V0U3RhdGUsCiAgW1N0b3JlS2V5LkFjY2Vzc106IHVzZUFjY2Vzc1N0b3JlLnNldFN0YXRlLAogIFtTdG9yZUtleS5Db25maWddOiB1c2VBcHBDb25maWcuc2V0U3RhdGUsCiAgW1N0b3JlS2V5Lk1hc2tdOiB1c2VNYXNrU3RvcmUuc2V0U3RhdGUsCiAgW1N0b3JlS2V5LlByb21wdF06IHVzZVByb21wdFN0b3JlLnNldFN0YXRlLAp9IGFzIGNvbnN0OwoKY29uc3QgTG9jYWxTdGF0ZUdldHRlcnMgPSB7CiAgW1N0b3JlS2V5LkNoYXRdOiAoKSA9PiBnZXROb25GdW5jdGlvbkZpbGVkcyh1c2VDaGF0U3RvcmUuZ2V0U3RhdGUoKSksCiAgW1N0b3JlS2V5LkFjY2Vzc106ICgpID0+IGdldE5vbkZ1bmN0aW9uRmlsZWRzKHVzZUFjY2Vzc1N0b3JlLmdldFN0YXRlKCkpLAogIFtTdG9yZUtleS5Db25maWddOiAoKSA9PiBnZXROb25GdW5jdGlvbkZpbGVkcyh1c2VBcHBDb25maWcuZ2V0U3RhdGUoKSksCiAgW1N0b3JlS2V5Lk1hc2tdOiAoKSA9PiBnZXROb25GdW5jdGlvbkZpbGVkcyh1c2VNYXNrU3RvcmUuZ2V0U3RhdGUoKSksCiAgW1N0b3JlS2V5LlByb21wdF06ICgpID0+IGdldE5vbkZ1bmN0aW9uRmlsZWRzKHVzZVByb21wdFN0b3JlLmdldFN0YXRlKCkpLAp9IGFzIGNvbnN0OwoKZXhwb3J0IHR5cGUgQXBwU3RhdGUgPSB7CiAgW2sgaW4ga2V5b2YgdHlwZW9mIExvY2FsU3RhdGVHZXR0ZXJzXTogUmV0dXJuVHlwZTwKICAgICh0eXBlb2YgTG9jYWxTdGF0ZUdldHRlcnMpW2tdCiAgPjsKfTsKCnR5cGUgTWVyZ2VyPFQgZXh0ZW5kcyBrZXlvZiBBcHBTdGF0ZSwgVSA9IEFwcFN0YXRlW1RdPiA9ICgKICBsb2NhbFN0YXRlOiBVLAogIHJlbW90ZVN0YXRlOiBVLAopID0+IFU7Cgp0eXBlIFN0YXRlTWVyZ2VyID0gewogIFtLIGluIGtleW9mIEFwcFN0YXRlXTogTWVyZ2VyPEs+Owp9OwoKLy8gd2UgbWVyZ2UgcmVtb3RlIHN0YXRlIHRvIGxvY2FsIHN0YXRlCmNvbnN0IE1lcmdlU3RhdGVzOiBTdGF0ZU1lcmdlciA9IHsKICBbU3RvcmVLZXkuQ2hhdF06IChsb2NhbFN0YXRlLCByZW1vdGVTdGF0ZSkgPT4gewogICAgLy8gbWVyZ2Ugc2Vzc2lvbnMKICAgIGNvbnN0IGxvY2FsU2Vzc2lvbnM6IFJlY29yZDxzdHJpbmcsIENoYXRTZXNzaW9uPiA9IHt9OwogICAgbG9jYWxTdGF0ZS5zZXNzaW9ucy5mb3JFYWNoKChzKSA9PiAobG9jYWxTZXNzaW9uc1tzLmlkXSA9IHMpKTsKCiAgICByZW1vdGVTdGF0ZS5zZXNzaW9ucy5mb3JFYWNoKChyZW1vdGVTZXNzaW9uKSA9PiB7CiAgICAgIC8vIHNraXAgZW1wdHkgY2hhdHMKICAgICAgaWYgKHJlbW90ZVNlc3Npb24ubWVzc2FnZXMubGVuZ3RoID09PSAwKSByZXR1cm47CgogICAgICBjb25zdCBsb2NhbFNlc3Npb24gPSBsb2NhbFNlc3Npb25zW3JlbW90ZVNlc3Npb24uaWRdOwogICAgICBpZiAoIWxvY2FsU2Vzc2lvbikgewogICAgICAgIC8vIGlmIHJlbW90ZSBzZXNzaW9uIGlzIG5ldywganVzdCBtZXJnZSBpdAogICAgICAgIGxvY2FsU3RhdGUuc2Vzc2lvbnMucHVzaChyZW1vdGVTZXNzaW9uKTsKICAgICAgfSBlbHNlIHsKICAgICAgICAvLyBpZiBib3RoIGhhdmUgdGhlIHNhbWUgc2Vzc2lvbiBpZCwgbWVyZ2UgdGhlIG1lc3NhZ2VzCiAgICAgICAgY29uc3QgbG9jYWxNZXNzYWdlSWRzID0gbmV3IFNldChsb2NhbFNlc3Npb24ubWVzc2FnZXMubWFwKCh2KSA9PiB2LmlkKSk7CiAgICAgICAgcmVtb3RlU2Vzc2lvbi5tZXNzYWdlcy5mb3JFYWNoKChtKSA9PiB7CiAgICAgICAgICBpZiAoIWxvY2FsTWVzc2FnZUlkcy5oYXMobS5pZCkpIHsKICAgICAgICAgICAgbG9jYWxTZXNzaW9uLm1lc3NhZ2VzLnB1c2gobSk7CiAgICAgICAgICB9CiAgICAgICAgfSk7CgogICAgICAgIC8vIHNvcnQgbG9jYWwgbWVzc2FnZXMgd2l0aCBkYXRlIGZpZWxkIGluIGFzYyBvcmRlcgogICAgICAgIGxvY2FsU2Vzc2lvbi5tZXNzYWdlcy5zb3J0KAogICAgICAgICAgKGEsIGIpID0+IG5ldyBEYXRlKGEuZGF0ZSkuZ2V0VGltZSgpIC0gbmV3IERhdGUoYi5kYXRlKS5nZXRUaW1lKCksCiAgICAgICAgKTsKICAgICAgfQogICAgfSk7CgogICAgLy8gc29ydCBsb2NhbCBzZXNzaW9ucyB3aXRoIGRhdGUgZmllbGQgaW4gZGVzYyBvcmRlcgogICAgbG9jYWxTdGF0ZS5zZXNzaW9ucy5zb3J0KAogICAgICAoYSwgYikgPT4KICAgICAgICBuZXcgRGF0ZShiLmxhc3RVcGRhdGUpLmdldFRpbWUoKSAtIG5ldyBEYXRlKGEubGFzdFVwZGF0ZSkuZ2V0VGltZSgpLAogICAgKTsKCiAgICByZXR1cm4gbG9jYWxTdGF0ZTsKICB9LAogIFtTdG9yZUtleS5Qcm9tcHRdOiAobG9jYWxTdGF0ZSwgcmVtb3RlU3RhdGUpID0+IHsKICAgIGxvY2FsU3RhdGUucHJvbXB0cyA9IHsKICAgICAgLi4ucmVtb3RlU3RhdGUucHJvbXB0cywKICAgICAgLi4ubG9jYWxTdGF0ZS5wcm9tcHRzLAogICAgfTsKICAgIHJldHVybiBsb2NhbFN0YXRlOwogIH0sCiAgW1N0b3JlS2V5Lk1hc2tdOiAobG9jYWxTdGF0ZSwgcmVtb3RlU3RhdGUpID0+IHsKICAgIGxvY2FsU3RhdGUubWFza3MgPSB7CiAgICAgIC4uLnJlbW90ZVN0YXRlLm1hc2tzLAogICAgICAuLi5sb2NhbFN0YXRlLm1hc2tzLAogICAgfTsKICAgIHJldHVybiBsb2NhbFN0YXRlOwogIH0sCiAgW1N0b3JlS2V5LkNvbmZpZ106IG1lcmdlV2l0aFVwZGF0ZTxBcHBTdGF0ZVtTdG9yZUtleS5Db25maWddPiwKICBbU3RvcmVLZXkuQWNjZXNzXTogbWVyZ2VXaXRoVXBkYXRlPEFwcFN0YXRlW1N0b3JlS2V5LkFjY2Vzc10+LAp9OwoKZXhwb3J0IGZ1bmN0aW9uIGdldExvY2FsQXBwU3RhdGUoKSB7CiAgY29uc3QgYXBwU3RhdGUgPSBPYmplY3QuZnJvbUVudHJpZXMoCiAgICBPYmplY3QuZW50cmllcyhMb2NhbFN0YXRlR2V0dGVycykubWFwKChba2V5LCBnZXR0ZXJdKSA9PiB7CiAgICAgIHJldHVybiBba2V5LCBnZXR0ZXIoKV07CiAgICB9KSwKICApIGFzIEFwcFN0YXRlOwoKICByZXR1cm4gYXBwU3RhdGU7Cn0KCmV4cG9ydCBmdW5jdGlvbiBzZXRMb2NhbEFwcFN0YXRlKGFwcFN0YXRlOiBBcHBTdGF0ZSkgewogIE9iamVjdC5lbnRyaWVzKExvY2FsU3RhdGVTZXR0ZXJzKS5mb3JFYWNoKChba2V5LCBzZXR0ZXJdKSA9PiB7CiAgICBzZXR0ZXIoYXBwU3RhdGVba2V5IGFzIGtleW9mIEFwcFN0YXRlXSk7CiAgfSk7Cn0KCmV4cG9ydCBmdW5jdGlvbiBtZXJnZUFwcFN0YXRlKGxvY2FsU3RhdGU6IEFwcFN0YXRlLCByZW1vdGVTdGF0ZTogQXBwU3RhdGUpIHsKICBPYmplY3Qua2V5cyhsb2NhbFN0YXRlKS5mb3JFYWNoKDxUIGV4dGVuZHMga2V5b2YgQXBwU3RhdGU+KGs6IHN0cmluZykgPT4gewogICAgY29uc3Qga2V5ID0gayBhcyBUOwogICAgY29uc3QgbG9jYWxTdG9yZVN0YXRlID0gbG9jYWxTdGF0ZVtrZXldOwogICAgY29uc3QgcmVtb3RlU3RvcmVTdGF0ZSA9IHJlbW90ZVN0YXRlW2tleV07CiAgICBNZXJnZVN0YXRlc1trZXldKGxvY2FsU3RvcmVTdGF0ZSwgcmVtb3RlU3RvcmVTdGF0ZSk7CiAgfSk7CgogIHJldHVybiBsb2NhbFN0YXRlOwp9CgovKioKICogTWVyZ2Ugc3RhdGUgd2l0aCBgbGFzdFVwZGF0ZVRpbWVgLCBuZXdlciBzdGF0ZSB3aWxsIG92ZXJyaWRlCiAqCiAqIOWQiOW5tuaWueWQke+8mui+g+aWsOiAhSBpbi1wbGFjZSBpbi1wbGFjZSDopobnm5YgbG9jYWxTdGF0Ze+8jOWboOS4uuWQjue7rSBzZXRMb2NhbEFwcFN0YXRlIOS4jgogKiBjbGllbnQuc2V0IOmDveS7pSBsb2NhbFN0YXRlIOS4uuWHhuS4iuS8oOS6keerr+OAggogKiAtIHJlbW90ZSDmm7TmlrDvvJpyZW1vdGUg6KaG55uWIGxvY2Fs77yI5YaZ5YWlIGxvY2FsU3RhdGXvvInvvIzmnKzlnLDmi7/liLDov5znq6/mlrDlgLzlubbkuIrkvKAKICogLSBsb2NhbCDmm7TmlrDmiJbnm7jnrYnvvJrkv53nlZkgbG9jYWzvvIzkuI3orqnov5znq6/ml6flgLzopobnm5bvvIzmnKzlnLDmlrDlgLzmraPluLjkuIrkvKAKICovCmV4cG9ydCBmdW5jdGlvbiBtZXJnZVdpdGhVcGRhdGU8VCBleHRlbmRzIHsgbGFzdFVwZGF0ZVRpbWU/OiBudW1iZXIgfT4oCiAgbG9jYWxTdGF0ZTogVCwKICByZW1vdGVTdGF0ZTogVCwKKSB7CiAgY29uc3QgbG9jYWxVcGRhdGVUaW1lID0gbG9jYWxTdGF0ZS5sYXN0VXBkYXRlVGltZSA/PyAwOwogIGNvbnN0IHJlbW90ZVVwZGF0ZVRpbWUgPSByZW1vdGVTdGF0ZS5sYXN0VXBkYXRlVGltZSA/PyAwOwoKICBpZiAobG9jYWxVcGRhdGVUaW1lIDwgcmVtb3RlVXBkYXRlVGltZSkgewogICAgLy8gcmVtb3RlIOi+g+aWsO+8muiuqSByZW1vdGUg6KaG55uWIGxvY2Fs77yIaW4tcGxhY2Ug5L+u5pS5IGxvY2FsU3RhdGXvvIkKICAgIG1lcmdlKGxvY2FsU3RhdGUsIHJlbW90ZVN0YXRlKTsKICB9CiAgLy8gbG9jYWwg6L6D5paw5oiW55u4562J77ya5L+d55WZIGxvY2Fs77yM5LiN5ZCI5bm2IHJlbW90Ze+8jOmBv+WFjei/nOerr+aXp+WAvOimhuebluacrOWcsOaWsOWAvAogIHJldHVybiB7IC4uLmxvY2FsU3RhdGUgfTsKfQo= \ No newline at end of file