Files
actual/packages/desktop-client/src/sync-events.ts
Matiss Janis Aboltins 8690616f41 [AI] Chunked sync message application and progress UX for mobile
- 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>
2026-02-23 21:47:56 +00:00

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();
};
}