mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 10:14:53 -05:00
- Apply sync messages in batches (APPLY_MESSAGES_BATCH_SIZE) when count > 5000 to avoid blocking mobile - Emit sync-event progress (applied/total) during batched apply; client shows 'Applying sync... X%' - Add SyncEvent type 'progress'; handle in sync-events.ts and set loadingText - Add batched-apply and progress-emission tests in sync.test.ts - Fixes mobile browser stuck on Downloading (e.g. #6904) Co-authored-by: Cursor <cursoragent@cursor.com>
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import type { QueryClient } from '@tanstack/react-query';
|
|
import { t } from 'i18next';
|
|
|
|
import { listen, send } from 'loot-core/platform/client/connection';
|
|
|
|
import { accountQueries } from './accounts';
|
|
import { resetSync, setAppState, sync } from './app/appSlice';
|
|
import { categoryQueries } from './budget';
|
|
import {
|
|
closeAndDownloadBudget,
|
|
uploadBudget,
|
|
} from './budgetfiles/budgetfilesSlice';
|
|
import { pushModal } from './modals/modalsSlice';
|
|
import { addNotification } from './notifications/notificationsSlice';
|
|
import type { Notification } from './notifications/notificationsSlice';
|
|
import { payeeQueries } from './payees';
|
|
import { loadPrefs } from './prefs/prefsSlice';
|
|
import type { AppStore } from './redux/store';
|
|
import { signOut } from './users/usersSlice';
|
|
|
|
export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
|
|
const unlistenProgress = listen('sync-event', event => {
|
|
if (event.type !== 'progress') {
|
|
return;
|
|
}
|
|
|
|
const percent = Math.round((event.applied / event.total) * 100);
|
|
store.dispatch(
|
|
setAppState({
|
|
loadingText: t('Applying sync... {{percent}}%', { percent }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
// TODO: Should this run on mobile too?
|
|
const unlistenUnauthorized = listen('sync-event', async ({ type }) => {
|
|
if (type === 'unauthorized') {
|
|
store.dispatch(
|
|
addNotification({
|
|
notification: {
|
|
type: 'warning',
|
|
message: 'Unable to authenticate with server',
|
|
sticky: true,
|
|
id: 'auth-issue',
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
let attemptedSyncRepair = false;
|
|
|
|
const unlistenSuccess = listen('sync-event', event => {
|
|
const prefs = store.getState().prefs.local;
|
|
if (!prefs || !prefs.id) {
|
|
// Do nothing if no budget is loaded
|
|
return;
|
|
}
|
|
|
|
if (event.type === 'success' || event.type === 'applied') {
|
|
if (attemptedSyncRepair) {
|
|
attemptedSyncRepair = false;
|
|
|
|
store.dispatch(
|
|
addNotification({
|
|
notification: {
|
|
title: t('Syncing has been fixed!'),
|
|
message: t('Happy budgeting!'),
|
|
type: 'message',
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const tables = event.tables;
|
|
|
|
if (tables.includes('prefs')) {
|
|
void store.dispatch(loadPrefs());
|
|
}
|
|
|
|
if (
|
|
tables.includes('categories') ||
|
|
tables.includes('category_groups') ||
|
|
tables.includes('category_mapping')
|
|
) {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: categoryQueries.lists(),
|
|
});
|
|
}
|
|
|
|
if (
|
|
// Sync on accounts change because so that transfer payees are updated
|
|
tables.includes('accounts') ||
|
|
tables.includes('payees') ||
|
|
tables.includes('payee_mapping')
|
|
) {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: payeeQueries.lists(),
|
|
});
|
|
}
|
|
|
|
if (tables.includes('accounts')) {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: accountQueries.lists(),
|
|
});
|
|
}
|
|
} else if (event.type === 'error') {
|
|
let notif: Notification | null = null;
|
|
const learnMore = `[${t('Learn more')}](https://actualbudget.org/docs/getting-started/sync/#debugging-sync-issues)`;
|
|
const githubIssueLink =
|
|
'https://github.com/actualbudget/actual/issues/new?assignees=&labels=bug&template=bug-report.yml&title=%5BBug%5D%3A+';
|
|
|
|
switch (event.subtype) {
|
|
case 'out-of-sync':
|
|
if (attemptedSyncRepair) {
|
|
notif = {
|
|
title: t('Your data is still out of sync'),
|
|
message:
|
|
t(
|
|
'We were unable to repair your sync state, sorry! You need to reset your sync state.',
|
|
) +
|
|
' ' +
|
|
learnMore,
|
|
sticky: true,
|
|
id: 'reset-sync',
|
|
button: {
|
|
title: t('Reset sync'),
|
|
action: () => {
|
|
void store.dispatch(resetSync());
|
|
},
|
|
},
|
|
};
|
|
} else {
|
|
// A bug happened during the sync process. Sync state needs
|
|
// to be reset.
|
|
notif = {
|
|
title: t('Your data is out of sync'),
|
|
message:
|
|
t(
|
|
'There was a problem syncing your data. We can try to repair your sync state to fix it.',
|
|
) +
|
|
' ' +
|
|
learnMore,
|
|
type: 'warning',
|
|
sticky: true,
|
|
id: 'repair-sync',
|
|
button: {
|
|
title: t('Repair'),
|
|
action: async () => {
|
|
attemptedSyncRepair = true;
|
|
await send('sync-repair');
|
|
void store.dispatch(sync());
|
|
},
|
|
},
|
|
};
|
|
}
|
|
break;
|
|
|
|
case 'file-old-version':
|
|
// Tell the user something is wrong with the key state on
|
|
// the server and the key needs to be recreated
|
|
notif = {
|
|
title: t('Actual has updated the syncing format'),
|
|
message: t(
|
|
'This happens rarely (if ever again). The internal syncing format ' +
|
|
'has changed and you need to reset sync. This will upload data from ' +
|
|
'this device and revert all other devices. ' +
|
|
'[Learn more about what this means](https://actualbudget.org/docs/getting-started/sync/#what-does-resetting-sync-mean).' +
|
|
'\n\n' +
|
|
'Old encryption keys are not migrated. If using encryption, [reset encryption here](#makeKey).',
|
|
),
|
|
messageActions: {
|
|
makeKey: () =>
|
|
store.dispatch(
|
|
pushModal({
|
|
modal: { name: 'create-encryption-key', options: {} },
|
|
}),
|
|
),
|
|
},
|
|
sticky: true,
|
|
id: 'old-file',
|
|
button: {
|
|
title: t('Reset sync'),
|
|
action: () => {
|
|
void store.dispatch(resetSync());
|
|
},
|
|
},
|
|
};
|
|
break;
|
|
|
|
case 'file-key-mismatch':
|
|
// Tell the user something is wrong with the key state on
|
|
// the server and the key needs to be recreated
|
|
notif = {
|
|
title: t('Your encryption key need to be reset'),
|
|
message:
|
|
t(
|
|
'Something went wrong when registering your encryption key id. ' +
|
|
'You need to recreate your key. ',
|
|
) + learnMore,
|
|
sticky: true,
|
|
id: 'invalid-key-state',
|
|
button: {
|
|
title: t('Reset key'),
|
|
action: () => {
|
|
store.dispatch(
|
|
pushModal({
|
|
modal: { name: 'create-encryption-key', options: {} },
|
|
}),
|
|
);
|
|
},
|
|
},
|
|
};
|
|
|
|
break;
|
|
|
|
case 'file-not-found':
|
|
notif = {
|
|
title: t('This file is not a cloud file'),
|
|
message:
|
|
t(
|
|
'You need to register it to take advantage ' +
|
|
'of syncing which allows you to use it across devices and never worry ' +
|
|
'about losing your data.',
|
|
) +
|
|
' ' +
|
|
learnMore,
|
|
type: 'warning',
|
|
sticky: true,
|
|
id: 'register-file',
|
|
button: {
|
|
title: t('Register'),
|
|
action: async () => {
|
|
await store.dispatch(uploadBudget({}));
|
|
void store.dispatch(sync());
|
|
void store.dispatch(loadPrefs());
|
|
},
|
|
},
|
|
};
|
|
break;
|
|
|
|
case 'file-needs-upload':
|
|
notif = {
|
|
title: t('File needs upload'),
|
|
message:
|
|
t(
|
|
'Something went wrong when creating this cloud file. You need ' +
|
|
'to upload this file to fix it.',
|
|
) +
|
|
' ' +
|
|
learnMore,
|
|
sticky: true,
|
|
id: 'upload-file',
|
|
button: {
|
|
title: t('Upload'),
|
|
action: () => {
|
|
void store.dispatch(resetSync());
|
|
},
|
|
},
|
|
};
|
|
break;
|
|
|
|
case 'file-has-reset':
|
|
case 'file-has-new-key':
|
|
// These two cases happen when the current group or key on
|
|
// the server does not match the local one. This can mean a
|
|
// few things depending on the state, and we try to show an
|
|
// appropriate message and call to action to fix it.
|
|
const { cloudFileId } = store.getState().prefs.local;
|
|
if (!cloudFileId) {
|
|
console.error(
|
|
'Received file-has-reset or file-has-new-key error but no cloudFileId in prefs',
|
|
);
|
|
break;
|
|
}
|
|
|
|
notif = {
|
|
title: t('Syncing has been reset on this cloud file'),
|
|
message:
|
|
t(
|
|
'You need to revert it to continue syncing. Any unsynced ' +
|
|
'data will be lost. If you like, you can instead ' +
|
|
'[upload this file](#upload) to be the latest version.',
|
|
) +
|
|
' ' +
|
|
learnMore,
|
|
messageActions: { upload: () => store.dispatch(resetSync()) },
|
|
sticky: true,
|
|
id: 'needs-revert',
|
|
button: {
|
|
title: t('Revert'),
|
|
action: () => {
|
|
void store.dispatch(closeAndDownloadBudget({ cloudFileId }));
|
|
},
|
|
},
|
|
};
|
|
break;
|
|
case 'encrypt-failure':
|
|
case 'decrypt-failure':
|
|
if (event.meta?.isMissingKey) {
|
|
notif = {
|
|
title: t('Missing encryption key'),
|
|
message: t(
|
|
'Unable to encrypt your data because you are missing the key. ' +
|
|
'Create your key to sync your data.',
|
|
),
|
|
sticky: true,
|
|
id: 'encrypt-failure-missing',
|
|
button: {
|
|
title: t('Create key'),
|
|
action: () => {
|
|
store.dispatch(
|
|
pushModal({
|
|
modal: {
|
|
name: 'fix-encryption-key',
|
|
options: {
|
|
onSuccess: () => store.dispatch(sync()),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
},
|
|
};
|
|
} else {
|
|
notif = {
|
|
message: t(
|
|
'Unable to encrypt your data. You have the correct ' +
|
|
'key so this is likely an internal failure. To fix this, ' +
|
|
'reset your sync data with a new key.',
|
|
),
|
|
sticky: true,
|
|
id: 'encrypt-failure',
|
|
button: {
|
|
title: t('Reset key'),
|
|
action: () => {
|
|
store.dispatch(
|
|
pushModal({
|
|
modal: { name: 'create-encryption-key', options: {} },
|
|
}),
|
|
);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
break;
|
|
case 'invalid-schema':
|
|
console.trace('invalid-schema', event.meta);
|
|
notif = {
|
|
title: t('Update required'),
|
|
message: t(
|
|
"We couldn't apply changes from the server. This probably means you " +
|
|
'need to update the app to support the latest database.',
|
|
),
|
|
type: 'warning',
|
|
};
|
|
break;
|
|
case 'apply-failure':
|
|
console.trace('apply-failure', event.meta);
|
|
notif = {
|
|
message: t(
|
|
"We couldn't apply that change to the database. Please report this as a bug by [opening a GitHub issue]({{githubIssueLink}}).",
|
|
{ githubIssueLink },
|
|
),
|
|
};
|
|
break;
|
|
case 'network':
|
|
// Show nothing
|
|
break;
|
|
case 'clock-drift':
|
|
notif = {
|
|
title: t('Time sync issue'),
|
|
message: t(
|
|
'Failed to sync because your device time differs too much from the server. Please check your device time settings and ensure they are correct.',
|
|
),
|
|
type: 'warning',
|
|
sticky: true,
|
|
};
|
|
break;
|
|
case 'token-expired':
|
|
notif = null;
|
|
void store.dispatch(signOut());
|
|
break;
|
|
default:
|
|
console.trace('unknown error', event);
|
|
notif = {
|
|
message: t(
|
|
'We had problems syncing your changes. Please report this as a bug by [opening a GitHub issue]({{githubIssueLink}}).',
|
|
{ githubIssueLink },
|
|
),
|
|
};
|
|
}
|
|
|
|
if (notif) {
|
|
store.dispatch(
|
|
addNotification({ notification: { type: 'error', ...notif } }),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unlistenProgress();
|
|
unlistenUnauthorized();
|
|
unlistenSuccess();
|
|
};
|
|
}
|