diff --git a/lib/actions/backup-actions.js b/lib/actions/backup-actions.js --- a/lib/actions/backup-actions.js +++ b/lib/actions/backup-actions.js @@ -19,4 +19,14 @@ +latestDatabaseVersion: number, }; -export { createUserKeysBackupActionTypes, createUserDataBackupActionTypes }; +const restoreUserDataStepActionTypes = Object.freeze({ + started: 'RESTORE_USER_DATA_STEP_STARTED', + success: 'RESTORE_USER_DATA_STEP_SUCCESS', + failed: 'RESTORE_USER_DATA_STEP_FAILED', +}); + +export { + createUserKeysBackupActionTypes, + createUserDataBackupActionTypes, + restoreUserDataStepActionTypes, +}; diff --git a/lib/backup/use-user-data-restore.js b/lib/backup/use-user-data-restore.js --- a/lib/backup/use-user-data-restore.js +++ b/lib/backup/use-user-data-restore.js @@ -1,23 +1,32 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { runRestoredBackupMigrations } from './restored-migrations.js'; +import { restoreUserDataStepActionTypes } from '../actions/backup-actions.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { logTypes, useDebugLogs } from '../components/debug-logs-context.js'; +import { + restoreUserDataSteps, + type RestoreUserDataStep, +} from '../types/backup-types.js'; import { databaseIdentifier } from '../types/database-identifier-types.js'; import type { IdentityAuthResult } from '../types/identity-service-types.js'; import { syncedMetadataNames } from '../types/synced-metadata-types.js'; import type { QRAuthBackupData } from '../types/tunnelbroker/qr-code-auth-message-types.js'; import { getConfig } from '../utils/config.js'; -import { useDispatch } from '../utils/redux-utils.js'; +import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; +import { useSelector, useDispatch } from '../utils/redux-utils.js'; function useUserDataRestore(): ( backupData: QRAuthBackupData, identityAuthResult: IdentityAuthResult, ) => Promise { const dispatch = useDispatch(); + const dispatchActionPromise = useDispatchActionPromise(); const { addLog } = useDebugLogs(); + const restoreBackupState = useSelector(state => state.restoreBackupState); return React.useCallback( async ( @@ -26,91 +35,153 @@ ) => { const { sqliteAPI } = getConfig(); - // 1. Download database and apply all logs - await sqliteAPI.restoreUserData(backupData, identityAuthResult); + // Determine starting step based on current state + const stepOrder: $ReadOnlyArray = [ + restoreUserDataSteps.RESTORE_DATABASE, + restoreUserDataSteps.MIGRATE_BACKUP_SCHEMA, + restoreUserDataSteps.RUN_RESTORED_BACKUP_MIGRATIONS, + restoreUserDataSteps.COPY_CONTENT_FROM_BACKUP_DB, + ]; + let startStepIndex = 0; - // 2. Check database versions and migrate if needed - const [mainDatabaseVersion, restoredDatabaseVersion] = await Promise.all([ - sqliteAPI.getDatabaseVersion(databaseIdentifier.MAIN), - sqliteAPI.getDatabaseVersion(databaseIdentifier.RESTORED), - ]); - - if (mainDatabaseVersion === restoredDatabaseVersion) { - addLog( - 'User Data Restore', - `Main and restored database versions are equal: ` + - `${mainDatabaseVersion}, skipping schema migrations.`, - new Set([logTypes.BACKUP]), - ); - } else if (mainDatabaseVersion > restoredDatabaseVersion) { - addLog( - 'User Data Restore', - `Main database version (${mainDatabaseVersion}) is higher ` + - `than restored database version (${restoredDatabaseVersion}), ` + - `migrating schema.`, - new Set([logTypes.BACKUP]), + if (restoreBackupState.status === 'user_data_restore_step_completed') { + const completedStepIndex = stepOrder.indexOf( + restoreBackupState.payload.step, ); - await sqliteAPI.migrateBackupSchema(); - } else if (mainDatabaseVersion < restoredDatabaseVersion) { - addLog( - 'User Data Restore', - `Main database version (${mainDatabaseVersion}) is lower ` + - `than restored database version (${restoredDatabaseVersion}), ` + - ` aborting.`, - new Set([logTypes.BACKUP, logTypes.ERROR]), - ); - throw new Error('backup_is_newer'); + startStepIndex = completedStepIndex + 1; + } else if (restoreBackupState.status === 'user_data_restore_started') { + startStepIndex = stepOrder.indexOf(restoreBackupState.payload.step); + } else { + // for any other state, start from scratch + startStepIndex = 0; } - // 3. Check store versions and migrate if needed - const mainStoreVersion = getConfig().platformDetails.stateVersion; - const restoredStoreVersionString = await sqliteAPI.getSyncedMetadata( - syncedMetadataNames.STORE_VERSION, - databaseIdentifier.RESTORED, - ); + // if all steps from last restore succeeded, start from scratch + if (startStepIndex > 3) { + startStepIndex = 0; + } + + invariant(startStepIndex >= 0, 'invalid UserData restore step'); - if (!mainStoreVersion || !restoredStoreVersionString) { - addLog( - 'User Data Restore', - `Error when restoring user data, main store version(${ - mainStoreVersion ?? 'undefined' - }) or restored store version (${ - restoredStoreVersionString ?? 'undefined' - }) are undefined`, - new Set([logTypes.BACKUP, logTypes.ERROR]), + // 1. Download database and apply all logs + if (startStepIndex === 0) { + await dispatchActionPromise( + restoreUserDataStepActionTypes, + sqliteAPI.restoreUserData(backupData, identityAuthResult), + undefined, + { step: 'restore_database' }, ); - return; } - const restoredStoreVersion = parseInt(restoredStoreVersionString); + // 2. Check database versions and migrate if needed + if (startStepIndex <= 1) { + await dispatchActionPromise( + restoreUserDataStepActionTypes, + (async () => { + const [mainDatabaseVersion, restoredDatabaseVersion] = + await Promise.all([ + sqliteAPI.getDatabaseVersion(databaseIdentifier.MAIN), + sqliteAPI.getDatabaseVersion(databaseIdentifier.RESTORED), + ]); - if (mainStoreVersion === restoredStoreVersion) { - addLog( - 'User Data Restore', - `Main and restored store versions are equal: ${mainStoreVersion}, ` + - `skipping data migrations`, - new Set([logTypes.BACKUP]), + if (mainDatabaseVersion === restoredDatabaseVersion) { + addLog( + 'User Data Restore', + `Main and restored database versions are equal: ` + + `${mainDatabaseVersion}, skipping schema migrations.`, + new Set([logTypes.BACKUP]), + ); + } else if (mainDatabaseVersion > restoredDatabaseVersion) { + addLog( + 'User Data Restore', + `Main database version (${mainDatabaseVersion}) is higher ` + + `than restored database version (${restoredDatabaseVersion}), ` + + `migrating schema.`, + new Set([logTypes.BACKUP]), + ); + await sqliteAPI.migrateBackupSchema(); + } else if (mainDatabaseVersion < restoredDatabaseVersion) { + addLog( + 'User Data Restore', + `Main database version (${mainDatabaseVersion}) is lower ` + + `than restored database version (${restoredDatabaseVersion}), ` + + ` aborting.`, + new Set([logTypes.BACKUP, logTypes.ERROR]), + ); + throw new Error('backup_is_newer'); + } + })(), + undefined, + { step: 'migrate_backup_schema' }, ); - } else if (mainStoreVersion > restoredStoreVersion) { - addLog( - 'User Data Restore', - `Main store version (${mainStoreVersion}) is higher than ` + - `restored store version (${restoredStoreVersion}), migrating data`, - new Set([logTypes.BACKUP]), - ); - await runRestoredBackupMigrations(); - } else if (mainStoreVersion < restoredStoreVersion) { - addLog( - 'User Data Restore', - `Main store version (${mainStoreVersion}) is lower than ` + - `restored store version (${restoredStoreVersion}), aborting`, - new Set([logTypes.BACKUP, logTypes.ERROR]), + } + + // 3. Check store versions and migrate if needed + if (startStepIndex <= 2) { + await dispatchActionPromise( + restoreUserDataStepActionTypes, + (async () => { + const mainStoreVersion = getConfig().platformDetails.stateVersion; + const restoredStoreVersionString = + await sqliteAPI.getSyncedMetadata( + syncedMetadataNames.STORE_VERSION, + databaseIdentifier.RESTORED, + ); + + if (!mainStoreVersion || !restoredStoreVersionString) { + addLog( + 'User Data Restore', + `Error when restoring user data, main store version(${ + mainStoreVersion ?? 'undefined' + }) or restored store version (${ + restoredStoreVersionString ?? 'undefined' + }) are undefined`, + new Set([logTypes.BACKUP, logTypes.ERROR]), + ); + throw new Error('version_check_failed'); + } + + const restoredStoreVersion = parseInt(restoredStoreVersionString); + + if (mainStoreVersion === restoredStoreVersion) { + addLog( + 'User Data Restore', + `Main and restored store versions are equal: ${mainStoreVersion}, ` + + `skipping data migrations`, + new Set([logTypes.BACKUP]), + ); + } else if (mainStoreVersion > restoredStoreVersion) { + addLog( + 'User Data Restore', + `Main store version (${mainStoreVersion}) is higher than ` + + `restored store version (${restoredStoreVersion}), migrating data`, + new Set([logTypes.BACKUP]), + ); + await runRestoredBackupMigrations(); + } else if (mainStoreVersion < restoredStoreVersion) { + addLog( + 'User Data Restore', + `Main store version (${mainStoreVersion}) is lower than ` + + `restored store version (${restoredStoreVersion}), aborting`, + new Set([logTypes.BACKUP, logTypes.ERROR]), + ); + throw new Error('backup_is_newer'); + } + })(), + undefined, + { step: 'run_restored_backup_migrations' }, ); - throw new Error('backup_is_newer'); } // 4. Copy content to main database - await sqliteAPI.copyContentFromBackupDatabase(); + if (startStepIndex <= 3) { + await dispatchActionPromise( + restoreUserDataStepActionTypes, + sqliteAPI.copyContentFromBackupDatabase(), + undefined, + { step: 'copy_content_from_backup_db' }, + ); + } // 5. Populate store const clientDBStore = await sqliteAPI.getClientDBStore( @@ -123,7 +194,7 @@ payload: clientDBStore, }); }, - [addLog, dispatch], + [addLog, dispatch, dispatchActionPromise, restoreBackupState], ); } diff --git a/lib/reducers/backup-reducer.js b/lib/reducers/backup-reducer.js --- a/lib/reducers/backup-reducer.js +++ b/lib/reducers/backup-reducer.js @@ -5,12 +5,18 @@ import { createUserDataBackupActionTypes, createUserKeysBackupActionTypes, + restoreUserDataStepActionTypes, } from '../actions/backup-actions.js'; import { changeIdentityUserPasswordActionTypes, restoreUserActionTypes, } from '../actions/user-actions.js'; -import type { BackupStore, RestoreBackupState } from '../types/backup-types.js'; +import { + restoreUserDataSteps, + type BackupStore, + type RestoreBackupState, + type RestoreUserDataStep, +} from '../types/backup-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullBackupSupport } from '../utils/services-utils.js'; @@ -90,7 +96,78 @@ return { ...store, status: 'user_data_backup_failed' }; } + // restoreUserDataStepActionTypes + if (action.type === restoreUserDataStepActionTypes.started) { + const { step } = action.payload; + validateUserDataRestoreStepOrder(store, step); + + return { + status: 'user_data_restore_started', + payload: { step }, + }; + } else if (action.type === restoreUserDataStepActionTypes.success) { + // For success actions, we need to get the current step from the store state + if (store.status === 'user_data_restore_started') { + const { step } = store.payload; + // If this was the last step, mark restore as completed + if (step === 'copy_content_from_backup_db') { + return { + status: 'user_data_restore_completed', + payload: {}, + }; + } + return { + status: 'user_data_restore_step_completed', + payload: { step }, + }; + } + return store; + } else if (action.type === restoreUserDataStepActionTypes.failed) { + // For failed actions, we need to get the current step from the store state + if (store.status === 'user_data_restore_started') { + const { payload: error } = action; + const { step } = store.payload; + return { + status: 'user_data_restore_failed', + payload: { step, error }, + }; + } + return store; + } + return store; } +const stepOrder: $ReadOnlyArray = [ + restoreUserDataSteps.RESTORE_DATABASE, + restoreUserDataSteps.MIGRATE_BACKUP_SCHEMA, + restoreUserDataSteps.RUN_RESTORED_BACKUP_MIGRATIONS, + restoreUserDataSteps.COPY_CONTENT_FROM_BACKUP_DB, +]; +function validateUserDataRestoreStepOrder( + store: RestoreBackupState, + step: RestoreUserDataStep, +) { + const currentStepIndex = stepOrder.indexOf(step); + + if (store.status === 'user_data_restore_step_completed') { + const lastCompletedStepIndex = stepOrder.indexOf(store.payload.step); + invariant( + currentStepIndex === lastCompletedStepIndex + 1, + `Invalid step order: trying to start '${step}' but last completed step was '${store.payload.step}'`, + ); + } else if (store.status === 'user_data_restore_started') { + invariant( + currentStepIndex === stepOrder.indexOf(store.payload.step), + `Invalid step: trying to restart '${step}' but current step is '${store.payload.step}'`, + ); + } else { + // Starting fresh - should always start with first step + invariant( + step === 'restore_database', + `Invalid starting step: expected 'restore_database' but got '${step}'`, + ); + } +} + export { reduceBackupStore, reduceRestoreBackupState }; diff --git a/lib/types/backup-types.js b/lib/types/backup-types.js --- a/lib/types/backup-types.js +++ b/lib/types/backup-types.js @@ -82,6 +82,14 @@ +latestDatabaseVersion?: number, }; +export const restoreUserDataSteps = Object.freeze({ + RESTORE_DATABASE: 'restore_database', + MIGRATE_BACKUP_SCHEMA: 'migrate_backup_schema', + RUN_RESTORED_BACKUP_MIGRATIONS: 'run_restored_backup_migrations', + COPY_CONTENT_FROM_BACKUP_DB: 'copy_content_from_backup_db', +}); +export type RestoreUserDataStep = $Values; + export type RestoreBackupState = | { status: 'no_backup', @@ -110,4 +118,27 @@ | { status: 'user_data_backup_failed', payload: {}, + } + | { + status: 'user_data_restore_started', + payload: { + step: RestoreUserDataStep, + }, + } + | { + status: 'user_data_restore_step_completed', + payload: { + step: RestoreUserDataStep, + }, + } + | { + status: 'user_data_restore_failed', + payload: { + step: RestoreUserDataStep, + error: Error, + }, + } + | { + status: 'user_data_restore_completed', + payload: {}, }; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -32,6 +32,7 @@ import type { LocalLatestBackupInfo, RestoreBackupState, + RestoreUserDataStep, } from './backup-types.js'; import type { CommunityStore, @@ -1646,6 +1647,22 @@ +payload: Error, +loadingInfo: LoadingInfo, } + | { + +type: 'RESTORE_USER_DATA_STEP_STARTED', + +loadingInfo: LoadingInfo, + +payload: { +step: RestoreUserDataStep }, + } + | { + +type: 'RESTORE_USER_DATA_STEP_SUCCESS', + +payload?: void, + +loadingInfo: LoadingInfo, + } + | { + +type: 'RESTORE_USER_DATA_STEP_FAILED', + +error: true, + +payload: Error, + +loadingInfo: LoadingInfo, + } | { +type: 'RESTORE_USER_STARTED', +loadingInfo: LoadingInfo,