From fb3b4e8e50d9b4f67dcc671ecec9d91e018718ae Mon Sep 17 00:00:00 2001 From: thekingofcity <3353040+thekingofcity@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:02:27 +0800 Subject: [PATCH] #327 Hash based optimistic locking for syncing saves --- .../menu/account-view/saves-section.tsx | 212 ++++++++++-------- .../modal/resolve-conflict-modal.tsx | 38 ++-- src/redux/account/account-slice.ts | 207 +++++++++++++---- src/redux/init.test.ts | 88 +++++++- src/redux/init.ts | 55 ++++- src/redux/rmp-save/rmp-save-slice.ts | 44 ++-- src/util/local-storage-save.ts | 64 ++---- src/util/rmp-sync.test.ts | 71 ++++++ src/util/rmp-sync.ts | 68 ++++++ 9 files changed, 608 insertions(+), 239 deletions(-) create mode 100644 src/util/rmp-sync.test.ts create mode 100644 src/util/rmp-sync.ts diff --git a/src/components/menu/account-view/saves-section.tsx b/src/components/menu/account-view/saves-section.tsx index d8b2afb..8e7cac0 100644 --- a/src/components/menu/account-view/saves-section.tsx +++ b/src/components/menu/account-view/saves-section.tsx @@ -7,11 +7,12 @@ import { MdDeleteOutline, MdOutlineShare, MdOutlineSync, MdOutlineSyncAlt } from import { useRootDispatch, useRootSelector } from '../../../redux'; import { fetchSaveList, logout, syncAfterLogin } from '../../../redux/account/account-slice'; import { addNotification } from '../../../redux/notification/notification-slice'; -import { setLastChangedAtTimeStamp } from '../../../redux/rmp-save/rmp-save-slice'; +import { clearBaseSync, setBaseSync } from '../../../redux/rmp-save/rmp-save-slice'; import { apiFetch } from '../../../util/api'; import { API_ENDPOINT, APISaveList, SAVE_KEY } from '../../../util/constants'; import { getRMPSave, notifyRMPSaveChange, setRMPSave } from '../../../util/local-storage-save'; import { getRandomCityName } from '../../../util/random-save-name'; +import { resolveBoundSaveId } from '../../../util/rmp-sync'; import InlineEdit from '../../common/inline-edit'; import ShareModal from './share-modal'; @@ -23,12 +24,21 @@ const SavesSection = () => { const dispatch = useRootDispatch(); const { isLoggedIn, + id: userId, token, activeSubscriptions, currentSaveId, saves: saveList, } = useRootSelector(state => state.account); - const { lastChangedAtTimeStamp } = useRootSelector(state => state.rmpSave); + const { baseUserId, baseSaveId } = useRootSelector(state => state.rmpSave); + + const localCurrentSaveId = resolveBoundSaveId({ + currentUserId: userId, + currentSaveId, + saves: saveList, + baseUserId, + baseSaveId, + }); const canCreateNewSave = isLoggedIn && @@ -55,7 +65,7 @@ const SavesSection = () => { const handleCreateNewSave = async () => { const save = await getRMPSave(SAVE_KEY.RMP); - if (!isLoggedIn || !save || !token) { + if (!isLoggedIn || !save || !token || !userId) { showErrorToast(t('Failed to get the RMP save!')); return; } @@ -79,104 +89,68 @@ const SavesSection = () => { showErrorToast(await rep.text()); return; } - dispatch(fetchSaveList()); + + const savesRep = await dispatch(fetchSaveList()); + if (savesRep.meta.requestStatus === 'fulfilled') { + const payload = savesRep.payload as APISaveList; + if (payload.currentSaveId) { + dispatch(setBaseSync({ userId, saveId: payload.currentSaveId, hash })); + } + } } catch (e) { showErrorToast((e as Error).message); } }; + const handleSync = async (saveId: number) => { - if (!isLoggedIn || !token) return; - if (saveId === currentSaveId) { - // current sync, either fetch cloud (a) or update cloud (b) - setSyncButtonIsLoading(currentSaveId); - if (!currentSaveId || isUpdateDisabled(currentSaveId)) { + if (!isLoggedIn || !token || !userId) return; + + if (saveId === localCurrentSaveId) { + setSyncButtonIsLoading(saveId); + if (isUpdateDisabled(saveId)) { showErrorToast(t('Can not sync this save!')); setSyncButtonIsLoading(undefined); return; } - // fetch cloud save metadata - const savesRep = await dispatch(fetchSaveList()); - if (savesRep.meta.requestStatus !== 'fulfilled') { - showErrorToast(t('Login status expired.')); // TODO: also might be !200 response - setSyncButtonIsLoading(undefined); - return; - } - const savesList = savesRep.payload as APISaveList; - const cloudSave = savesList.saves.filter(save => save.id === currentSaveId).at(0); - if (!cloudSave) { - showErrorToast(t(`Current save id is not in saveList!`)); - // TODO: ask sever to reconstruct currentSaveId - setSyncButtonIsLoading(undefined); - return; - } - const lastUpdateAt = new Date(cloudSave.lastUpdateAt); - const lastChangedAt = new Date(lastChangedAtTimeStamp); - // a. if cloud save is newer, fetch and set the cloud save to local - if (lastChangedAt < lastUpdateAt) { - logger.warn(`Save id: ${currentSaveId} is newer in the cloud via local compare.`); - // TODO: There is no compare just fetch and set the cloud save to local - // might be better to have a dedicated thunk action for this - dispatch(syncAfterLogin()); - setSyncButtonIsLoading(undefined); - return; + const rep = await dispatch(syncAfterLogin()); + if (rep.meta.requestStatus === 'rejected' && rep.payload) { + showErrorToast(String(rep.payload)); } + setSyncButtonIsLoading(undefined); + return; + } - // b. local save is newer, update the cloud save - const save = await getRMPSave(SAVE_KEY.RMP); - if (!save) { - showErrorToast(t('Failed to get the RMP save!')); - setSyncButtonIsLoading(undefined); - return; - } - const { data, hash } = save; - const rep = await apiFetch( - API_ENDPOINT.SAVES + '/' + currentSaveId, - { - method: 'PATCH', - body: JSON.stringify({ data, hash }), - }, - token - ); - if (rep.status === 401) { - showErrorToast(t('Login status expired.')); - setSyncButtonIsLoading(undefined); - dispatch(logout()); - return; - } - if (rep.status !== 200) { - showErrorToast(await rep.text()); - setSyncButtonIsLoading(undefined); - return; - } + setSyncButtonIsLoading(saveId); + const saveInfo = saveList.find(save => save.id === saveId); + const rep = await apiFetch(API_ENDPOINT.SAVES + '/' + saveId, {}, token); + if (rep.status === 401) { + showErrorToast(t('Login status expired.')); setSyncButtonIsLoading(undefined); - } else { - // sync another save slot - setSyncButtonIsLoading(saveId); - const rep = await apiFetch(API_ENDPOINT.SAVES + '/' + saveId, {}, token); - if (rep.status === 401) { - showErrorToast(t('Login status expired.')); - setSyncButtonIsLoading(undefined); - dispatch(logout()); - return; - } - if (rep.status !== 200) { - showErrorToast(await rep.text()); - setSyncButtonIsLoading(undefined); - return; - } - logger.info(`Set ${SAVE_KEY.RMP} with save id: ${saveId}`); - setRMPSave(SAVE_KEY.RMP, await rep.text()); - dispatch(setLastChangedAtTimeStamp(new Date().valueOf())); - notifyRMPSaveChange(); + dispatch(logout()); + return; + } + if (rep.status !== 200) { + showErrorToast(await rep.text()); setSyncButtonIsLoading(undefined); + return; + } + logger.info(`Set ${SAVE_KEY.RMP} with save id: ${saveId}`); + setRMPSave(SAVE_KEY.RMP, await rep.text()); + if (saveInfo) { + dispatch(setBaseSync({ userId, saveId, hash: saveInfo.hash })); } + notifyRMPSaveChange(); + setSyncButtonIsLoading(undefined); dispatch(fetchSaveList()); }; + const handleDeleteSave = async (saveId: number) => { - if (!isLoggedIn || !saveId || !token) return; + if (!isLoggedIn || !saveId || !token || !userId) return; + const isDeletingBoundSave = saveId === localCurrentSaveId; + setDeleteButtonIsLoading(saveId); - const rep = await apiFetch(API_ENDPOINT.SAVES + '/' + currentSaveId, { method: 'DELETE' }, token); + const rep = await apiFetch(API_ENDPOINT.SAVES + '/' + saveId, { method: 'DELETE' }, token); if (rep.status === 401) { showErrorToast(t('Login status expired.')); setDeleteButtonIsLoading(undefined); @@ -188,9 +162,49 @@ const SavesSection = () => { setDeleteButtonIsLoading(undefined); return; } - dispatch(fetchSaveList()); + + const savesRep = await dispatch(fetchSaveList()); + if (savesRep.meta.requestStatus !== 'fulfilled') { + setDeleteButtonIsLoading(undefined); + return; + } + + if (!isDeletingBoundSave) { + setDeleteButtonIsLoading(undefined); + return; + } + + const payload = savesRep.payload as APISaveList; + const replacementSaveId = payload.currentSaveId; + const replacementSave = payload.saves.find(save => save.id === replacementSaveId); + + if (!replacementSaveId || !replacementSave) { + dispatch(clearBaseSync()); + setDeleteButtonIsLoading(undefined); + return; + } + + const replacementRep = await apiFetch(API_ENDPOINT.SAVES + '/' + replacementSaveId, {}, token); + if (replacementRep.status === 401) { + showErrorToast(t('Login status expired.')); + dispatch(logout()); + setDeleteButtonIsLoading(undefined); + return; + } + if (replacementRep.status !== 200) { + showErrorToast(await replacementRep.text()); + dispatch(clearBaseSync()); + setDeleteButtonIsLoading(undefined); + return; + } + + logger.info(`Set ${SAVE_KEY.RMP} with replacement save id: ${replacementSaveId}`); + setRMPSave(SAVE_KEY.RMP, await replacementRep.text()); + dispatch(setBaseSync({ userId, saveId: replacementSaveId, hash: replacementSave.hash })); + notifyRMPSaveChange(); setDeleteButtonIsLoading(undefined); }; + const handleEditSaveName = async (saveId: number, newName: string) => { if (!isLoggedIn || !saveId || !token) return; const rep = await apiFetch( @@ -227,29 +241,29 @@ const SavesSection = () => { - {saveList?.map(_ => ( - + {saveList?.map(save => ( + handleEditSaveName(_.id, val)} + initialValue={save.index} + onSave={val => handleEditSaveName(save.id, val)} textInputWidth="177px" /> handleSync(_.id)} - title={_.id === currentSaveId ? t('Sync now') : t('Sync this slot')} + disabled={isUpdateDisabled(save.id)} + loading={syncButtonIsLoading === save.id} + onClick={() => handleSync(save.id)} + title={save.id === localCurrentSaveId ? t('Sync now') : t('Sync this slot')} > - {_.id === currentSaveId ? : } + {save.id === localCurrentSaveId ? : } - handleOpenShareModal(_.id)} title={t('Share')}> + handleOpenShareModal(save.id)} title={t('Share')}> handleDeleteSave(_.id)} + loading={deleteButtonIsLoading === save.id} + onClick={() => handleDeleteSave(save.id)} title={t('Delete')} > @@ -257,14 +271,14 @@ const SavesSection = () => { - {t('ID')}: {_.id} + {t('ID')}: {save.id} - {t('Status')}: {_.id === currentSaveId ? t('Current save') : t('Cloud save')} + {t('Status')}: {save.id === localCurrentSaveId ? t('Current save') : t('Cloud save')} - {t('Last update at')}: {new Date(_.lastUpdateAt).toLocaleString()} + {t('Last update at')}: {new Date(save.lastUpdateAt).toLocaleString()} ))} @@ -272,7 +286,7 @@ const SavesSection = () => { setShareModalOpened(false)} - shareSaveInfo={saveList?.find(s => s.id === shareSaveID)} + shareSaveInfo={saveList?.find(save => save.id === shareSaveID)} /> ); diff --git a/src/components/modal/resolve-conflict-modal.tsx b/src/components/modal/resolve-conflict-modal.tsx index 5ccf97b..c8cd1a0 100644 --- a/src/components/modal/resolve-conflict-modal.tsx +++ b/src/components/modal/resolve-conflict-modal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { MdOutlineCloud, MdOutlineComputer } from 'react-icons/md'; import { useRootDispatch, useRootSelector } from '../../redux'; import { fetchSaveList, logout, syncAfterLogin } from '../../redux/account/account-slice'; -import { clearResolveConflictModal, setLastChangedAtTimeStamp } from '../../redux/rmp-save/rmp-save-slice'; +import { clearResolveConflictModal, setBaseSync } from '../../redux/rmp-save/rmp-save-slice'; import { SAVE_KEY } from '../../util/constants'; import { downloadAs } from '../../util/download'; import { getRMPSave, notifyRMPSaveChange, setRMPSave, updateSave } from '../../util/local-storage-save'; @@ -12,9 +12,9 @@ import { Button, Card, Flex, Group, Modal, Stack, Text } from '@mantine/core'; const ResolveConflictModal = () => { const { t } = useTranslation(); - const { token, refreshToken, currentSaveId } = useRootSelector(state => state.account); + const { id: userId, token } = useRootSelector(state => state.account); const { - resolveConflictModal: { isOpen, lastChangedAtTimeStamp, lastUpdatedAtTimeStamp, cloudData }, + resolveConflictModal: { isOpen, saveId, cloudData, cloudHash }, } = useRootSelector(state => state.rmpSave); const dispatch = useRootDispatch(); @@ -22,18 +22,19 @@ const ResolveConflictModal = () => { const onClose = () => dispatch(clearResolveConflictModal()); const replaceLocalWithCloud = () => { + if (!userId || !saveId || !cloudHash) return; setRMPSave(SAVE_KEY.RMP, cloudData); + dispatch(setBaseSync({ userId, saveId, hash: cloudHash })); notifyRMPSaveChange(); - dispatch(setLastChangedAtTimeStamp(lastUpdatedAtTimeStamp)); onClose(); }; const downloadCloud = () => { - downloadAs(`RMP_${lastUpdatedAtTimeStamp}.json`, 'application/json', cloudData); + downloadAs(`RMP_cloud_${Date.now()}.json`, 'application/json', cloudData); }; const replaceCloudWithLocal = async () => { - if (!currentSaveId || !token || !refreshToken) return; + if (!saveId || !token || !cloudHash || !userId) return; setReplaceCloudWithLocalLoading(true); - const rep = await updateSave(currentSaveId, token, refreshToken, SAVE_KEY.RMP); + const rep = await updateSave(saveId, token, SAVE_KEY.RMP, cloudHash); if (!rep) { dispatch(logout()); setReplaceCloudWithLocalLoading(false); @@ -44,15 +45,23 @@ const ResolveConflictModal = () => { setReplaceCloudWithLocalLoading(false); return; } - if (rep.status !== 200) return; + if (rep.status !== 200) { + setReplaceCloudWithLocalLoading(false); + return; + } + + const localSave = await getRMPSave(SAVE_KEY.RMP); + if (localSave) { + dispatch(setBaseSync({ userId, saveId, hash: localSave.hash })); + } dispatch(fetchSaveList()); setReplaceCloudWithLocalLoading(false); onClose(); }; const downloadLocal = async () => { - // fetchLogin will handle local save that does not exist - const { data: localData } = (await getRMPSave(SAVE_KEY.RMP))!; - downloadAs(`RMP_${lastChangedAtTimeStamp}.json`, 'application/json', localData); + const localSave = await getRMPSave(SAVE_KEY.RMP); + if (!localSave) return; + downloadAs(`RMP_local_${Date.now()}.json`, 'application/json', localSave.data); }; return ( @@ -75,9 +84,6 @@ const ResolveConflictModal = () => { {t('Local save')} - - {t('Update at:')} {new Date(lastChangedAtTimeStamp).toLocaleString()} - diff --git a/src/redux/account/account-slice.ts b/src/redux/account/account-slice.ts index bc49ed8..9b0bed1 100644 --- a/src/redux/account/account-slice.ts +++ b/src/redux/account/account-slice.ts @@ -10,10 +10,11 @@ import { APISubscription, SAVE_KEY, } from '../../util/constants'; -import { getRMPSave, notifyRMPSaveChange, setRMPSave } from '../../util/local-storage-save'; +import { getRMPSave, notifyRMPSaveChange, setRMPSave, updateSave } from '../../util/local-storage-save'; +import { decideSyncAction, resolveBoundSaveId } from '../../util/rmp-sync'; import { RootState } from '../index'; import { addNotification } from '../notification/notification-slice'; -import { setLastChangedAtTimeStamp, setResolveConflictModal } from '../rmp-save/rmp-save-slice'; +import { setBaseSync, setResolveConflictModal } from '../rmp-save/rmp-save-slice'; type DateTimeString = `${string}T${string}Z`; export interface ActiveSubscriptions { @@ -64,6 +65,21 @@ export interface LoginInfo { refreshExpires: string; } +const getSaveById = (saves: APISaveInfo[], saveId?: number) => saves.find(save => save.id === saveId); + +type FetchCloudSaveDataResult = { status: 200; data: string } | { status: 401 } | { status: number; message: string }; + +const fetchCloudSaveData = async (saveId: number, token: string): Promise => { + const rep = await apiFetch(API_ENDPOINT.SAVES + '/' + saveId, {}, token); + if (rep.status === 401) { + return { status: 401 as const }; + } + if (rep.status !== 200) { + return { status: rep.status, message: await rep.text() }; + } + return { status: 200 as const, data: await rep.text() }; +}; + export const fetchSaveList = createAsyncThunk( 'account/getSaveList', async (_, { getState, dispatch, rejectWithValue }) => { @@ -75,7 +91,7 @@ export const fetchSaveList = createAsyncThunk( return rejectWithValue('Can not recover from expired refresh token.'); } if (rep.status !== 200) { - return rejectWithValue(rep.text); + return rejectWithValue(await rep.text()); } return (await rep.json()) as APISaveList; } @@ -116,53 +132,164 @@ export const fetchLogin = createAsyncThunk<{ error?: string; username?: string } } ); -/** - * Fetch the cloud save and see which one is newer. - * If the cloud save is newer, update the local save with the cloud save. - * If the local save is newer, prompt the user to choose between local and cloud. - */ export const syncAfterLogin = createAsyncThunk( 'account/syncAfterLogin', async (_, { getState, dispatch, rejectWithValue }) => { - logger.debug('Sync after login - check if local save is newer'); - const state = getState() as RootState; + logger.debug('Sync RMP save with cloud via base hash.'); + + let state = getState() as RootState; const { - account: { isLoggedIn, token, currentSaveId, saves }, - rmpSave: { lastChangedAtTimeStamp }, + account: { + isLoggedIn, + id: initialUserId, + token: initialToken, + currentSaveId: initialCurrentSaveId, + saves: initialSaves, + }, } = state; - const lastChangedAt = new Date(lastChangedAtTimeStamp); - const save = saves.filter(save => save.id === currentSaveId).at(0); - if (!isLoggedIn || !save) { - // TODO: ask sever to reconstruct currentSaveId - return rejectWithValue(`Save id: ${currentSaveId} is not in saveList!`); + let userId = initialUserId; + let token = initialToken; + let currentSaveId = initialCurrentSaveId; + let saves = initialSaves; + let baseUserId = state.rmpSave.baseUserId; + let baseSaveId = state.rmpSave.baseSaveId; + let baseHash = state.rmpSave.baseHash; + + if (!isLoggedIn || !userId || !token) { + return rejectWithValue('No token.'); } - const lastUpdateAt = new Date(save.lastUpdateAt); - const rep = await apiFetch(API_ENDPOINT.SAVES + '/' + currentSaveId, {}, token); - if (rep.status === 401) { - dispatch(logout()); - return rejectWithValue('Login status expired.'); + + let saveId = resolveBoundSaveId({ + currentUserId: userId, + currentSaveId, + saves, + baseUserId, + baseSaveId, + }); + let save = getSaveById(saves, saveId); + + if (!save) { + const saveListRep = await dispatch(fetchSaveList()); + if (saveListRep.meta.requestStatus !== 'fulfilled') { + return rejectWithValue('Unable to refresh save list.'); + } + + state = getState() as RootState; + ({ id: userId, token, currentSaveId, saves } = state.account); + ({ baseUserId, baseSaveId, baseHash } = state.rmpSave); + + saveId = resolveBoundSaveId({ + currentUserId: userId, + currentSaveId, + saves, + baseUserId, + baseSaveId, + }); + save = getSaveById(saves, saveId); } - if (rep.status !== 200) { - return rejectWithValue(await rep.text()); + + if (!userId || !token || !saveId || !save) { + return rejectWithValue(`Save id: ${saveId} is not in saveList!`); + } + + const localSave = await getRMPSave(SAVE_KEY.RMP); + const localHash = localSave?.hash; + const activeBaseHash = baseUserId === userId && baseSaveId === saveId ? baseHash : undefined; + const action = decideSyncAction({ + localHash, + baseHash: activeBaseHash, + cloudHash: save.hash, + }); + + logger.debug( + `Sync action=${action}, saveId=${saveId}, localHash=${localHash}, baseHash=${activeBaseHash}, cloudHash=${save.hash}` + ); + + if (action === 'noop') { + return; } - const cloudData = await rep.text(); - const localData = await getRMPSave(SAVE_KEY.RMP); - if (lastChangedAt <= lastUpdateAt || !localData) { - // update newer cloud to local (lastChangedAt <= saves[currentSaveId].lastUpdateAt) - logger.info(`Set ${SAVE_KEY.RMP} with save id: ${currentSaveId}`); - setRMPSave(SAVE_KEY.RMP, cloudData); - dispatch(setLastChangedAtTimeStamp(new Date().valueOf())); + + if (action === 'align') { + dispatch(setBaseSync({ userId, saveId, hash: save.hash })); + return; + } + + if (action === 'pull') { + const cloudRep = await fetchCloudSaveData(saveId, token); + if (cloudRep.status === 401) { + dispatch(logout()); + return rejectWithValue('Login status expired.'); + } + if (cloudRep.status !== 200) { + return rejectWithValue('message' in cloudRep ? cloudRep.message : 'Unable to fetch cloud save.'); + } + + logger.info(`Set ${SAVE_KEY.RMP} with save id: ${saveId}`); + setRMPSave(SAVE_KEY.RMP, 'data' in cloudRep ? cloudRep.data : ''); + dispatch(setBaseSync({ userId, saveId, hash: save.hash })); notifyRMPSaveChange(); - } else { - // prompt user to choose between local and cloud (lastChangedAt > saves[currentSaveId].lastUpdateAt) - dispatch( - setResolveConflictModal({ - lastChangedAtTimeStamp: lastChangedAt.valueOf(), - lastUpdatedAtTimeStamp: lastUpdateAt.valueOf(), - cloudData: cloudData, - }) - ); + return; + } + + if (action === 'push') { + const rep = await updateSave(saveId, token, SAVE_KEY.RMP, activeBaseHash); + if (!rep) { + dispatch(logout()); + return rejectWithValue('Login status expired.'); + } + + if (rep.status === 409) { + const saveListRep = await dispatch(fetchSaveList()); + if (saveListRep.meta.requestStatus !== 'fulfilled') { + return rejectWithValue('Unable to refresh save list after conflict.'); + } + state = getState() as RootState; + const latestSave = getSaveById(state.account.saves, saveId); + if (!latestSave) { + return rejectWithValue(`Save id: ${saveId} is not in saveList!`); + } + const cloudRep = await fetchCloudSaveData(saveId, token); + if (cloudRep.status === 401) { + dispatch(logout()); + return rejectWithValue('Login status expired.'); + } + if (cloudRep.status !== 200) { + return rejectWithValue('message' in cloudRep ? cloudRep.message : 'Unable to fetch cloud save.'); + } + dispatch( + setResolveConflictModal({ + saveId, + cloudData: 'data' in cloudRep ? cloudRep.data : '', + cloudHash: latestSave.hash, + }) + ); + return; + } + + if (rep.status !== 200) { + return rejectWithValue(await rep.text()); + } + + dispatch(setBaseSync({ userId, saveId, hash: localHash! })); + await dispatch(fetchSaveList()); + return; } + + const cloudRep = await fetchCloudSaveData(saveId, token); + if (cloudRep.status === 401) { + dispatch(logout()); + return rejectWithValue('Login status expired.'); + } + if (cloudRep.status !== 200) { + return rejectWithValue('message' in cloudRep ? cloudRep.message : 'Unable to fetch cloud save.'); + } + dispatch( + setResolveConflictModal({ + saveId, + cloudData: 'data' in cloudRep ? cloudRep.data : '', + cloudHash: save.hash, + }) + ); } ); diff --git a/src/redux/init.test.ts b/src/redux/init.test.ts index 774fa21..d9d6920 100644 --- a/src/redux/init.test.ts +++ b/src/redux/init.test.ts @@ -1,7 +1,7 @@ import rootReducer, { RootStore } from './index'; import { createTestStore } from '../test-utils'; import { LocalStorageKey, WorkspaceTab } from '../util/constants'; -import { initActiveTab, initOpenedTabs, openSearchedApp } from './init'; +import { initActiveTab, initOpenedTabs, initRMPSaveStore, openSearchedApp } from './init'; import { showDevtools } from './app/app-slice'; import rmgRuntime from '@railmapgen/rmg-runtime'; @@ -104,4 +104,90 @@ describe('ReduxInit', () => { expect(openedTabs[0].url).toBe('/rmp/?id=123'); }); }); + + describe('ReduxInit - initRMPSaveStore', () => { + afterEach(() => { + rmgRuntime.storage.clear(); + }); + + it('Can restore base sync metadata from storage', () => { + const mockStore = createTestStore(); + rmgRuntime.storage.set( + LocalStorageKey.RMP_SAVE, + JSON.stringify({ + baseUserId: 11, + baseSaveId: 22, + baseHash: 'hash-22', + }) + ); + + initRMPSaveStore(mockStore); + + expect(mockStore.getState().rmpSave.baseUserId).toBe(11); + expect(mockStore.getState().rmpSave.baseSaveId).toBe(22); + expect(mockStore.getState().rmpSave.baseHash).toBe('hash-22'); + expect(rmgRuntime.storage.get(LocalStorageKey.RMP_SAVE)).toBe( + JSON.stringify({ + baseUserId: 11, + baseSaveId: 22, + baseHash: 'hash-22', + }) + ); + }); + + it('Can clean legacy timestamp-based metadata from storage', () => { + const mockStore = createTestStore(); + rmgRuntime.storage.set( + LocalStorageKey.RMP_SAVE, + JSON.stringify({ + lastChangedAtTimeStamp: 1742000000000, + resolveConflictModal: { + isOpen: true, + lastChangedAtTimeStamp: 1742000000000, + lastUpdatedAtTimeStamp: 1742000001000, + cloudData: '{"legacy":true}', + }, + }) + ); + + initRMPSaveStore(mockStore); + + expect(mockStore.getState().rmpSave.baseUserId).toBeUndefined(); + expect(mockStore.getState().rmpSave.baseSaveId).toBeUndefined(); + expect(mockStore.getState().rmpSave.baseHash).toBeUndefined(); + expect(rmgRuntime.storage.get(LocalStorageKey.RMP_SAVE)).toBe(JSON.stringify({})); + }); + + it('Can discard unused legacy fields while preserving current base sync metadata', () => { + const mockStore = createTestStore(); + rmgRuntime.storage.set( + LocalStorageKey.RMP_SAVE, + JSON.stringify({ + baseUserId: 11, + baseSaveId: 22, + baseHash: 'hash-22', + lastChangedAtTimeStamp: 1742000000000, + resolveConflictModal: { + isOpen: true, + lastChangedAtTimeStamp: 1742000000000, + lastUpdatedAtTimeStamp: 1742000001000, + cloudData: '{"legacy":true}', + }, + }) + ); + + initRMPSaveStore(mockStore); + + expect(mockStore.getState().rmpSave.baseUserId).toBe(11); + expect(mockStore.getState().rmpSave.baseSaveId).toBe(22); + expect(mockStore.getState().rmpSave.baseHash).toBe('hash-22'); + expect(rmgRuntime.storage.get(LocalStorageKey.RMP_SAVE)).toBe( + JSON.stringify({ + baseUserId: 11, + baseSaveId: 22, + baseHash: 'hash-22', + }) + ); + }); + }); }); diff --git a/src/redux/init.ts b/src/redux/init.ts index ca27d42..de278c2 100644 --- a/src/redux/init.ts +++ b/src/redux/init.ts @@ -16,7 +16,22 @@ import { showDevtools, } from './app/app-slice'; import { RootStore, startRootListening } from './index'; -import { RMPSaveState, setLastChangedAtTimeStamp } from './rmp-save/rmp-save-slice'; +import { RMPSaveState, setBaseSync } from './rmp-save/rmp-save-slice'; + +type PersistedRMPSaveState = Pick; + +const normalizePersistedRMPSaveState = (raw: unknown): PersistedRMPSaveState => { + if (!raw || typeof raw !== 'object') { + return {}; + } + + const { baseUserId, baseSaveId, baseHash } = raw as Record; + if (typeof baseUserId === 'number' && typeof baseSaveId === 'number' && typeof baseHash === 'string' && baseHash) { + return { baseUserId, baseSaveId, baseHash }; + } + + return {}; +}; export const initShowDevtools = (store: RootStore) => { const lastShowDevTools = Number(rmgRuntime.storage.get(LocalStorageKey.LAST_SHOW_DEVTOOLS)); @@ -107,14 +122,28 @@ export const initRMPSaveStore = (store: RootStore) => { const rmpSaveString = rmgRuntime.storage.get(LocalStorageKey.RMP_SAVE); if (rmpSaveString) { - const rmpSaveData = JSON.parse(rmpSaveString) as Pick; - logger.debug(`Get RMP save data from local storage: ${JSON.stringify(rmpSaveData)}`); - store.dispatch(setLastChangedAtTimeStamp(rmpSaveData.lastChangedAtTimeStamp)); + try { + const rmpSaveData = normalizePersistedRMPSaveState(JSON.parse(rmpSaveString)); + logger.debug(`Get RMP save data from local storage: ${JSON.stringify(rmpSaveData)}`); + + if (rmpSaveData.baseUserId && rmpSaveData.baseSaveId && rmpSaveData.baseHash) { + store.dispatch( + setBaseSync({ + userId: rmpSaveData.baseUserId, + saveId: rmpSaveData.baseSaveId, + hash: rmpSaveData.baseHash, + }) + ); + } + + // Rewrite legacy or mixed-format data so only the active sync fields remain. + rmgRuntime.storage.set(LocalStorageKey.RMP_SAVE, JSON.stringify(rmpSaveData)); + } catch (e) { + logger.warn('Invalid RMP save data from local storage. Resetting persisted sync metadata.', e); + rmgRuntime.storage.set(LocalStorageKey.RMP_SAVE, JSON.stringify({})); + } } else { - // Default to 0 on fresh start and will be overwritten on login. - // (cloud lastUpdateAt must be greater than lastChangedAt(0)) - logger.warn('No RMP save data from local storage. Setting lastChangedAtTimeStamp to 0.'); - store.dispatch(setLastChangedAtTimeStamp(0)); + logger.warn('No RMP save data from local storage. Base sync metadata will be rebuilt on demand.'); } }; @@ -181,11 +210,15 @@ export default function initStore(store: RootStore) { startRootListening({ predicate: (_action, currentState, previousState) => { - return currentState.rmpSave.lastChangedAtTimeStamp !== previousState.rmpSave.lastChangedAtTimeStamp; + return ( + currentState.rmpSave.baseUserId !== previousState.rmpSave.baseUserId || + currentState.rmpSave.baseSaveId !== previousState.rmpSave.baseSaveId || + currentState.rmpSave.baseHash !== previousState.rmpSave.baseHash + ); }, effect: (_action, listenerApi) => { - const { lastChangedAtTimeStamp } = listenerApi.getState().rmpSave; - rmgRuntime.storage.set(LocalStorageKey.RMP_SAVE, JSON.stringify({ lastChangedAtTimeStamp })); + const { baseUserId, baseSaveId, baseHash } = listenerApi.getState().rmpSave; + rmgRuntime.storage.set(LocalStorageKey.RMP_SAVE, JSON.stringify({ baseUserId, baseSaveId, baseHash })); }, }); diff --git a/src/redux/rmp-save/rmp-save-slice.ts b/src/redux/rmp-save/rmp-save-slice.ts index 4bdf212..e0e974b 100644 --- a/src/redux/rmp-save/rmp-save-slice.ts +++ b/src/redux/rmp-save/rmp-save-slice.ts @@ -1,27 +1,26 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export interface RMPSaveState { - /** - * The last time the user made a change to the save. - * Will be compared with the lastUpdateAt from the cloud to determine if there is a conflict on login. - * Will be set to now on every save. - */ - lastChangedAtTimeStamp: number; + baseUserId?: number; + baseSaveId?: number; + baseHash?: string; resolveConflictModal: { isOpen: boolean; - lastChangedAtTimeStamp: number; - lastUpdatedAtTimeStamp: number; + saveId?: number; cloudData: string; + cloudHash?: string; }; } const initialState: RMPSaveState = { - lastChangedAtTimeStamp: 0, + baseUserId: undefined, + baseSaveId: undefined, + baseHash: undefined, resolveConflictModal: { isOpen: false, - lastChangedAtTimeStamp: 0, - lastUpdatedAtTimeStamp: 0, + saveId: undefined, cloudData: '', + cloudHash: undefined, }, }; @@ -29,30 +28,37 @@ const rmpSaveSlice = createSlice({ name: 'save', initialState, reducers: { - setLastChangedAtTimeStamp: (state, action: PayloadAction) => { - state.lastChangedAtTimeStamp = action.payload; + setBaseSync: (state, action: PayloadAction<{ userId: number; saveId: number; hash: string }>) => { + state.baseUserId = action.payload.userId; + state.baseSaveId = action.payload.saveId; + state.baseHash = action.payload.hash; + }, + clearBaseSync: state => { + state.baseUserId = undefined; + state.baseSaveId = undefined; + state.baseHash = undefined; }, setResolveConflictModal: ( state, - action: PayloadAction<{ lastChangedAtTimeStamp: number; lastUpdatedAtTimeStamp: number; cloudData: string }> + action: PayloadAction<{ saveId: number; cloudData: string; cloudHash: string }> ) => { state.resolveConflictModal = { isOpen: true, - lastChangedAtTimeStamp: action.payload.lastChangedAtTimeStamp, - lastUpdatedAtTimeStamp: action.payload.lastUpdatedAtTimeStamp, + saveId: action.payload.saveId, cloudData: action.payload.cloudData, + cloudHash: action.payload.cloudHash, }; }, clearResolveConflictModal: state => { state.resolveConflictModal = { isOpen: false, - lastChangedAtTimeStamp: 0, - lastUpdatedAtTimeStamp: 0, + saveId: undefined, cloudData: '', + cloudHash: undefined, }; }, }, }); -export const { setLastChangedAtTimeStamp, setResolveConflictModal, clearResolveConflictModal } = rmpSaveSlice.actions; +export const { setBaseSync, clearBaseSync, setResolveConflictModal, clearResolveConflictModal } = rmpSaveSlice.actions; export default rmpSaveSlice.reducer; diff --git a/src/util/local-storage-save.ts b/src/util/local-storage-save.ts index 03ce6db..7a50f57 100644 --- a/src/util/local-storage-save.ts +++ b/src/util/local-storage-save.ts @@ -1,7 +1,6 @@ import { logger } from '@railmapgen/rmg-runtime'; -import { fetchSaveList, logout } from '../redux/account/account-slice'; +import { syncAfterLogin } from '../redux/account/account-slice'; import { createStore } from '../redux/index'; -import { setLastChangedAtTimeStamp } from '../redux/rmp-save/rmp-save-slice'; import { apiFetch } from './api'; import { API_ENDPOINT, SAVE_KEY } from './constants'; import { createHash } from './utils'; @@ -45,13 +44,14 @@ let updateSaveTimeout: number | undefined; const SAVE_UPDATE_TIMEOUT_MS = 60 * 1000; // 1min export const registerOnRMPSaveChange = (store: ReturnType) => { - const eventHandler = async (ev: MessageEvent) => { + const eventHandler = (ev: MessageEvent) => { const { type, key, from } = ev.data; - if (type === SaveManagerEventType.SAVE_CHANGED && from === 'rmp') { - logger.info(`Received save changed event on key: ${key}`); - store.dispatch(setLastChangedAtTimeStamp(new Date().valueOf())); + if (type !== SaveManagerEventType.SAVE_CHANGED || from !== 'rmp') { + return; } + logger.info(`Received save changed event on key: ${key}`); + if (updateSaveTimeout) { return; } @@ -59,64 +59,26 @@ export const registerOnRMPSaveChange = (store: ReturnType) = updateSaveTimeout = window.setTimeout(async () => { updateSaveTimeout = undefined; - const { isLoggedIn, currentSaveId, token, refreshToken } = store.getState().account; - if (!isLoggedIn || !currentSaveId || !token || !refreshToken) return; - - const { type, key, from } = ev.data; - if (type === SaveManagerEventType.SAVE_CHANGED && from === 'rmp') { - logger.info(`Update save after timeout on key: ${key}`); - - if (!isLoggedIn || !currentSaveId) { - logger.warn('Not logged in or no current save id. No save update.'); - return; - } - - const { saves: saveList } = store.getState().account; - const save = saveList.filter(save => save.id === currentSaveId).at(0); - if (!save) { - logger.error(`Save id: ${currentSaveId} is not in saveList!`); - // TODO: ask the server to reconstruct currentSaveId - return; - } - - const lastUpdateAt = new Date(save.lastUpdateAt); - const { lastChangedAtTimeStamp } = store.getState().rmpSave; - const lastChangedAt = new Date(lastChangedAtTimeStamp); - if (lastChangedAt < lastUpdateAt) { - logger.warn(`Save id: ${currentSaveId} is newer in the cloud via local compare.`); - // do nothing until the local catch up with the cloud - return; - } + const { isLoggedIn, token } = store.getState().account; + if (!isLoggedIn || !token) return; - logger.info(`Update remote save id: ${currentSaveId} with local key: ${key}`); - const rep = await updateSave(currentSaveId, token, refreshToken, key!); - if (!rep) { - store.dispatch(logout()); - return; - } - if (rep.status === 409) { - logger.warn(`Save id: ${currentSaveId} is newer in the cloud via server response.`); - // do nothing until the local catch up with the cloud - return; - } - if (rep.status !== 200) return; - store.dispatch(fetchSaveList()); - } + logger.info(`Sync save after timeout on key: ${key}`); + store.dispatch(syncAfterLogin()); }, SAVE_UPDATE_TIMEOUT_MS); }; channel.addEventListener('message', eventHandler); }; -export const updateSave = async (currentSaveId: number, token: string, refreshToken: string, key: SAVE_KEY) => { +export const updateSave = async (saveId: number, token: string, key: SAVE_KEY, baseHash?: string) => { const save = await getRMPSave(key); if (!save) return undefined; const { data, hash } = save; const response = await apiFetch( - API_ENDPOINT.SAVES + '/' + currentSaveId, + API_ENDPOINT.SAVES + '/' + saveId, { method: 'PATCH', - body: JSON.stringify({ data, hash }), + body: JSON.stringify({ data, hash, baseHash }), }, token ); diff --git a/src/util/rmp-sync.test.ts b/src/util/rmp-sync.test.ts new file mode 100644 index 0000000..8f618bf --- /dev/null +++ b/src/util/rmp-sync.test.ts @@ -0,0 +1,71 @@ +import { APISaveInfo } from './constants'; +import { decideSyncAction, resolveBoundSaveId } from './rmp-sync'; + +const saves: APISaveInfo[] = [ + { id: 5, index: 'five', hash: 'hash-5', lastUpdateAt: '2025-01-01T00:00:00Z' }, + { id: 4, index: 'four', hash: 'hash-4', lastUpdateAt: '2025-01-01T00:00:00Z' }, +]; + +describe('rmp-sync', () => { + describe('resolveBoundSaveId', () => { + it('prefers the locally bound save when it belongs to the current user', () => { + expect( + resolveBoundSaveId({ + currentUserId: 1, + currentSaveId: 4, + saves, + baseUserId: 1, + baseSaveId: 5, + }) + ).toBe(5); + }); + + it('falls back to the server current save when local binding belongs to another user', () => { + expect( + resolveBoundSaveId({ + currentUserId: 2, + currentSaveId: 4, + saves, + baseUserId: 1, + baseSaveId: 5, + }) + ).toBe(4); + }); + + it('falls back to the newest save when the current save is missing', () => { + expect( + resolveBoundSaveId({ + currentUserId: 2, + currentSaveId: 99, + saves, + }) + ).toBe(5); + }); + }); + + describe('decideSyncAction', () => { + it('pulls the cloud save when there is no local save', () => { + expect(decideSyncAction({ cloudHash: 'cloud' })).toBe('pull'); + }); + + it('aligns metadata when legacy local and cloud data already match', () => { + expect(decideSyncAction({ localHash: 'same', cloudHash: 'same' })).toBe('align'); + }); + + it('pushes when only the local copy has changed', () => { + expect(decideSyncAction({ localHash: 'local', baseHash: 'base', cloudHash: 'base' })).toBe('push'); + }); + + it('pulls when only the cloud copy has changed', () => { + expect(decideSyncAction({ localHash: 'base', baseHash: 'base', cloudHash: 'cloud' })).toBe('pull'); + }); + + it('detects conflict when both local and cloud have changed', () => { + expect(decideSyncAction({ localHash: 'local', baseHash: 'base', cloudHash: 'cloud' })).toBe('conflict'); + }); + + it('does nothing when local and cloud both match the base', () => { + expect(decideSyncAction({ localHash: 'base', baseHash: 'base', cloudHash: 'base' })).toBe('noop'); + }); + }); +}); diff --git a/src/util/rmp-sync.ts b/src/util/rmp-sync.ts new file mode 100644 index 0000000..009a490 --- /dev/null +++ b/src/util/rmp-sync.ts @@ -0,0 +1,68 @@ +import { APISaveInfo } from './constants'; + +export type RmpSyncAction = 'noop' | 'align' | 'pull' | 'push' | 'conflict'; + +interface ResolveBoundSaveIdParams { + currentUserId?: number; + currentSaveId?: number; + saves: APISaveInfo[]; + baseUserId?: number; + baseSaveId?: number; +} + +interface DecideSyncActionParams { + localHash?: string; + baseHash?: string; + cloudHash?: string; +} + +export const resolveBoundSaveId = ({ + currentUserId, + currentSaveId, + saves, + baseUserId, + baseSaveId, +}: ResolveBoundSaveIdParams): number | undefined => { + const saveIds = new Set(saves.map(save => save.id)); + + if (currentUserId && baseUserId === currentUserId && baseSaveId && saveIds.has(baseSaveId)) { + return baseSaveId; + } + + if (currentSaveId && saveIds.has(currentSaveId)) { + return currentSaveId; + } + + return saves.at(0)?.id; +}; + +export const decideSyncAction = ({ localHash, baseHash, cloudHash }: DecideSyncActionParams): RmpSyncAction => { + if (!cloudHash) { + return localHash ? 'conflict' : 'noop'; + } + + if (!localHash) { + return 'pull'; + } + + if (!baseHash) { + return localHash === cloudHash ? 'align' : 'conflict'; + } + + const localDirty = localHash !== baseHash; + const cloudAdvanced = cloudHash !== baseHash; + + if (!localDirty && !cloudAdvanced) { + return 'noop'; + } + + if (!localDirty && cloudAdvanced) { + return 'pull'; + } + + if (localDirty && !cloudAdvanced) { + return 'push'; + } + + return 'conflict'; +};