diff --git a/packages/desktop-client/src/components/Titlebar.tsx b/packages/desktop-client/src/components/Titlebar.tsx index 748c29de9fc..c4972cf3b07 100644 --- a/packages/desktop-client/src/components/Titlebar.tsx +++ b/packages/desktop-client/src/components/Titlebar.tsx @@ -117,8 +117,8 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) { >(null); useEffect(() => { - const unlisten = listen('sync-event', ({ type, subtype, syncDisabled }) => { - if (type === 'start') { + const unlisten = listen('sync-event', event => { + if (event.type === 'start') { setSyncing(true); setSyncState(null); } else { @@ -130,19 +130,19 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) { }, 200); } - if (type === 'error') { + if (event.type === 'error') { // Use the offline state if either there is a network error or // if this file isn't a "cloud file". You can't sync a local // file. - if (subtype === 'network') { + if (event.subtype === 'network') { setSyncState('offline'); } else if (!cloudFileId) { setSyncState('local'); } else { setSyncState('error'); } - } else if (type === 'success') { - setSyncState(syncDisabled ? 'disabled' : null); + } else if (event.type === 'success') { + setSyncState(event.syncDisabled ? 'disabled' : null); } }); diff --git a/packages/desktop-client/src/components/budget/index.tsx b/packages/desktop-client/src/components/budget/index.tsx index 0012f46f0a7..43b0c4d62de 100644 --- a/packages/desktop-client/src/components/budget/index.tsx +++ b/packages/desktop-client/src/components/budget/index.tsx @@ -108,14 +108,16 @@ function BudgetInner(props: BudgetInnerProps) { run(); const unlistens = [ - listen('sync-event', ({ type, tables }) => { - if ( - type === 'success' && - (tables.includes('categories') || + listen('sync-event', event => { + if (event.type === 'success') { + const tables = event.tables; + if ( + tables.includes('categories') || tables.includes('category_mapping') || - tables.includes('category_groups')) - ) { - loadCategories(); + tables.includes('category_groups') + ) { + loadCategories(); + } } }), diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index 3b6c6780312..797035f941c 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -267,8 +267,9 @@ function TransactionListWithPreviews({ }, [accountId, dispatch]); useEffect(() => { - return listen('sync-event', ({ type, tables }) => { - if (type === 'applied') { + return listen('sync-event', event => { + if (event.type === 'applied') { + const tables = event.tables; if ( tables.includes('transactions') || tables.includes('category_mapping') || diff --git a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx index 4eee5f6e2cb..8a3eabb5891 100644 --- a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx @@ -62,8 +62,9 @@ export function CategoryTransactions({ const dateFormat = useDateFormat() || 'MM/dd/yyyy'; useEffect(() => { - return listen('sync-event', ({ type, tables }) => { - if (type === 'applied') { + return listen('sync-event', event => { + if (event.type === 'applied') { + const tables = event.tables; if ( tables.includes('transactions') || tables.includes('category_mapping') || diff --git a/packages/desktop-client/src/components/mobile/budget/index.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx index 20d233753e9..d2cc1645d65 100644 --- a/packages/desktop-client/src/components/mobile/budget/index.tsx +++ b/packages/desktop-client/src/components/mobile/budget/index.tsx @@ -69,15 +69,17 @@ export function Budget() { init(); - const unlisten = listen('sync-event', ({ type, tables }) => { - if ( - type === 'success' && - (tables.includes('categories') || + const unlisten = listen('sync-event', event => { + if (event.type === 'success') { + const tables = event.tables; + if ( + tables.includes('categories') || tables.includes('category_mapping') || - tables.includes('category_groups')) - ) { - // TODO: is this loading every time? - dispatch(getCategories()); + tables.includes('category_groups') + ) { + // TODO: is this loading every time? + dispatch(getCategories()); + } } }); diff --git a/packages/desktop-client/src/components/payees/ManagePayeesWithData.tsx b/packages/desktop-client/src/components/payees/ManagePayeesWithData.tsx index 064023fbad5..dedabaeb127 100644 --- a/packages/desktop-client/src/components/payees/ManagePayeesWithData.tsx +++ b/packages/desktop-client/src/components/payees/ManagePayeesWithData.tsx @@ -49,9 +49,9 @@ export function ManagePayeesWithData({ } loadData(); - const unlisten = listen('sync-event', async ({ type, tables }) => { - if (type === 'applied') { - if (tables.includes('rules')) { + const unlisten = listen('sync-event', async event => { + if (event.type === 'applied') { + if (event.tables.includes('rules')) { await refetchRuleCounts(); } } diff --git a/packages/desktop-client/src/global-events.ts b/packages/desktop-client/src/global-events.ts index 619f673b259..129073274e5 100644 --- a/packages/desktop-client/src/global-events.ts +++ b/packages/desktop-client/src/global-events.ts @@ -21,19 +21,20 @@ export function handleGlobalEvents(actions: BoundActions, store: Store) { }); }); - listen('schedules-offline', ({ payees }) => { - actions.pushModal('schedule-posts-offline-notification', { payees }); + listen('schedules-offline', () => { + actions.pushModal('schedule-posts-offline-notification'); }); // This is experimental: we sync data locally automatically when // data changes from the backend - listen('sync-event', async ({ type, tables }) => { + listen('sync-event', async event => { // We don't need to query anything until the file is loaded, and // sync events might come in if the file is being synced before // being loaded (happens when downloading) const prefs = store.getState().prefs.local; if (prefs && prefs.id) { - if (type === 'applied') { + if (event.type === 'applied') { + const tables = event.tables; if (tables.includes('payees') || tables.includes('payee_mapping')) { actions.getPayees(); } diff --git a/packages/loot-core/src/client/query-helpers.ts b/packages/loot-core/src/client/query-helpers.ts index c1e63e156c4..ee5f604ff8d 100644 --- a/packages/loot-core/src/client/query-helpers.ts +++ b/packages/loot-core/src/client/query-helpers.ts @@ -62,7 +62,7 @@ export class LiveQuery { private _data: Data; private _dependencies: Set; private _listeners: Array>; - private _supportedSyncTypes: Set; + private _supportedSyncTypes: Set<'applied' | 'success'>; private _query: Query; private _onError: (error: Error) => void; @@ -107,8 +107,8 @@ export class LiveQuery { // TODO: error types? this._supportedSyncTypes = options.onlySync - ? new Set(['success']) - : new Set(['applied', 'success']); + ? new Set(['success']) + : new Set(['applied', 'success']); if (onData) { this.addListener(onData); @@ -162,15 +162,18 @@ export class LiveQuery { protected subscribe = () => { if (this._unsubscribeSyncEvent == null) { - this._unsubscribeSyncEvent = listen('sync-event', ({ type, tables }) => { + this._unsubscribeSyncEvent = listen('sync-event', event => { // If the user is doing optimistic updates, they don't want to // always refetch whenever something changes because it would // refetch all data after they've already updated the UI. This // voids the perf benefits of optimistic updates. Allow querys // to only react to remote syncs. By default, queries will // always update to all changes. - if (this._supportedSyncTypes.has(type)) { - this.onUpdate(tables); + if ( + (event.type === 'applied' || event.type === 'success') && + this._supportedSyncTypes.has(event.type) + ) { + this.onUpdate(event.tables); } }); } diff --git a/packages/loot-core/src/client/shared-listeners.ts b/packages/loot-core/src/client/shared-listeners.ts index eb970fe6026..581faca40f1 100644 --- a/packages/loot-core/src/client/shared-listeners.ts +++ b/packages/loot-core/src/client/shared-listeners.ts @@ -8,16 +8,14 @@ import type { Notification } from './state-types/notifications'; export function listenForSyncEvent(actions, store) { let attemptedSyncRepair = false; - listen('sync-event', info => { - const { type, subtype, meta, tables } = info; - + listen('sync-event', event => { const prefs = store.getState().prefs.local; if (!prefs || !prefs.id) { // Do nothing if no budget is loaded return; } - if (type === 'success') { + if (event.type === 'success') { if (attemptedSyncRepair) { attemptedSyncRepair = false; @@ -28,6 +26,8 @@ export function listenForSyncEvent(actions, store) { }); } + const tables = event.tables; + if (tables.includes('prefs')) { actions.loadPrefs(); } @@ -47,7 +47,7 @@ export function listenForSyncEvent(actions, store) { if (tables.includes('accounts')) { actions.getAccounts(); } - } else if (type === 'error') { + } 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)', @@ -55,7 +55,7 @@ export function listenForSyncEvent(actions, store) { const githubIssueLink = 'https://github.com/actualbudget/actual/issues/new?assignees=&labels=bug&template=bug-report.yml&title=%5BBug%5D%3A+'; - switch (subtype) { + switch (event.subtype) { case 'out-of-sync': if (attemptedSyncRepair) { notif = { @@ -217,7 +217,7 @@ export function listenForSyncEvent(actions, store) { break; case 'encrypt-failure': case 'decrypt-failure': - if (meta.isMissingKey) { + if (event.meta.isMissingKey) { notif = { title: t('Missing encryption key'), message: t( @@ -254,7 +254,7 @@ export function listenForSyncEvent(actions, store) { } break; case 'invalid-schema': - console.trace('invalid-schema', meta); + console.trace('invalid-schema', event.meta); notif = { title: t('Update required'), message: t( @@ -265,7 +265,7 @@ export function listenForSyncEvent(actions, store) { }; break; case 'apply-failure': - console.trace('apply-failure', meta); + 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}}).', @@ -289,7 +289,7 @@ export function listenForSyncEvent(actions, store) { }; break; default: - console.trace('unknown error', info); + 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}}).', diff --git a/packages/loot-core/src/platform/server/connection/index.d.ts b/packages/loot-core/src/platform/server/connection/index.d.ts index 419e9726264..c0bbfbe5f7c 100644 --- a/packages/loot-core/src/platform/server/connection/index.d.ts +++ b/packages/loot-core/src/platform/server/connection/index.d.ts @@ -9,7 +9,7 @@ export type Init = typeof init; export function send( type: K, - args?: ServerEvents[k], + args?: ServerEvents[K], ): void; export type Send = typeof send; diff --git a/packages/loot-core/src/server/accounts/transactions.ts b/packages/loot-core/src/server/accounts/transactions.ts index c91d0d95f0a..cacc9dbd4b3 100644 --- a/packages/loot-core/src/server/accounts/transactions.ts +++ b/packages/loot-core/src/server/accounts/transactions.ts @@ -1,7 +1,7 @@ // @ts-strict-ignore import * as connection from '../../platform/server/connection'; import { Diff } from '../../shared/util'; -import { TransactionEntity } from '../../types/models'; +import { PayeeEntity, TransactionEntity } from '../../types/models'; import * as db from '../db'; import { incrFetch, whereIn } from '../db/util'; import { batchMessages } from '../sync'; @@ -55,7 +55,7 @@ export async function batchUpdateTransactions({ ? await idsWithChildren(deleted.map(d => d.id)) : []; - const oldPayees = new Set(); + const oldPayees = new Set(); const accounts = await db.all('SELECT * FROM accounts WHERE tombstone = 0'); // We need to get all the payees of updated transactions _before_ diff --git a/packages/loot-core/src/server/app.ts b/packages/loot-core/src/server/app.ts index fbb04a9110f..a8005a25fb5 100644 --- a/packages/loot-core/src/server/app.ts +++ b/packages/loot-core/src/server/app.ts @@ -1,15 +1,21 @@ // @ts-strict-ignore -import mitt from 'mitt'; +import mitt, { type Emitter } from 'mitt'; import { captureException } from '../platform/exceptions'; +import { ServerEvents } from '../types/server-events'; // This is a simple helper abstraction for defining methods exposed to // the client. It doesn't do much, but checks for naming conflicts and // makes it cleaner to combine methods. We call a group of related // methods an "app". +type Events = { + sync: ServerEvents['sync-event']; + 'load-budget': { id: string }; +}; + class App { - events; + events: Emitter; handlers: Handlers; services; unlistenServices; diff --git a/packages/loot-core/src/server/main-app.ts b/packages/loot-core/src/server/main-app.ts index 65c194a8220..2cde45a2334 100644 --- a/packages/loot-core/src/server/main-app.ts +++ b/packages/loot-core/src/server/main-app.ts @@ -6,6 +6,6 @@ import { createApp } from './app'; // Main app export const app = createApp(); -app.events.on('sync', info => { - connection.send('sync-event', info); +app.events.on('sync', event => { + connection.send('sync-event', event); }); diff --git a/packages/loot-core/src/server/schedules/app.ts b/packages/loot-core/src/server/schedules/app.ts index 67f31d367cf..a1ee93fbdc9 100644 --- a/packages/loot-core/src/server/schedules/app.ts +++ b/packages/loot-core/src/server/schedules/app.ts @@ -533,7 +533,7 @@ async function advanceSchedulesService(syncSuccess) { } if (failedToPost.length > 0) { - connection.send('schedules-offline', { payees: failedToPost }); + connection.send('schedules-offline'); } else if (didPost) { // This forces a full refresh of transactions because it // simulates them coming in from a full sync. This not a @@ -542,7 +542,7 @@ async function advanceSchedulesService(syncSuccess) { connection.send('sync-event', { type: 'success', tables: ['transactions'], - syncDisabled: 'false', + syncDisabled: false, }); } } diff --git a/packages/loot-core/src/types/server-events.d.ts b/packages/loot-core/src/types/server-events.d.ts index cd0dabf8514..15a2bdbcdd5 100644 --- a/packages/loot-core/src/types/server-events.d.ts +++ b/packages/loot-core/src/types/server-events.d.ts @@ -1,23 +1,86 @@ import { type Backup } from '../server/backups'; import { type UndoState } from '../server/undo'; +type SyncSubtype = + | 'out-of-sync' + | 'apply-failure' + | 'decrypt-failure' + | 'encrypt-failure' + | 'invalid-schema' + | 'network' + | 'file-old-version' + | 'file-key-mismatch' + | 'file-not-found' + | 'file-needs-upload' + | 'file-has-reset' + | 'file-has-new-key' + | 'token-expired' + | string; + +type SyncEvent = { + meta?: Record; +} & ( + | { + type: 'applied'; + tables: string[]; + data?: Map; + prevData?: Map; + } + | { + type: 'success'; + tables: string[]; + syncDisabled?: boolean; + } + | { + type: 'error'; + subtype?: SyncSubtype; + } + | { + type: 'start'; + } + | { + type: 'unauthorized'; + } +); + +type BackupUpdatedEvent = Backup[]; + +type CellsChangedEvent = Array<{ + name: string; + value: string | number | boolean; +}>; + +type FallbackWriteErrorEvent = undefined; +type FinishImportEvent = undefined; +type FinishLoadEvent = undefined; + +type OrphanedPayeesEvent = { + orphanedIds: string[]; + updatedPayeeIds: string[]; +}; + +type PrefsUpdatedEvent = undefined; +type SchedulesOfflineEvent = undefined; +type ServerErrorEvent = undefined; +type ShowBudgetsEvent = undefined; +type StartImportEvent = { budgetName: string }; +type StartLoadEvent = undefined; +type ApiFetchRedirectedEvent = undefined; + export interface ServerEvents { - 'backups-updated': Backup[]; - 'cells-changed': Array<{ name }>; - 'fallback-write-error': unknown; - 'finish-import': unknown; - 'finish-load': unknown; - 'orphaned-payees': { - orphanedIds: string[]; - updatedPayeeIds: string[]; - }; - 'prefs-updated': unknown; - 'schedules-offline': { payees: unknown[] }; - 'server-error': unknown; - 'show-budgets': unknown; - 'start-import': unknown; - 'start-load': unknown; - 'sync-event': { type; subtype; meta; tables; syncDisabled }; + 'backups-updated': BackupUpdatedEvent; + 'cells-changed': CellsChangedEvent; + 'fallback-write-error': FallbackWriteErrorEvent; + 'finish-import': FinishImportEvent; + 'finish-load': FinishLoadEvent; + 'orphaned-payees': OrphanedPayeesEvent; + 'prefs-updated': PrefsUpdatedEvent; + 'schedules-offline': SchedulesOfflineEvent; + 'server-error': ServerErrorEvent; + 'show-budgets': ShowBudgetsEvent; + 'start-import': StartImportEvent; + 'start-load': StartLoadEvent; + 'sync-event': SyncEvent; 'undo-event': UndoState; - 'api-fetch-redirected': unknown; + 'api-fetch-redirected': ApiFetchRedirectedEvent; }