[Typescript] Server event types (#4110)

* [Typescript] Server event types

* Release notes
This commit is contained in:
Joel Jeremy Marquez
2025-01-09 15:09:52 -08:00
committed by GitHub
parent 34ffc5c4b2
commit 92c93b3f6e
16 changed files with 159 additions and 74 deletions

View File

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

View File

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

View File

@@ -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') ||

View File

@@ -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') ||

View File

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

View File

@@ -48,9 +48,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();
}
}

View File

@@ -21,19 +21,20 @@ export function handleGlobalEvents(actions: BoundActions, store: Store<State>) {
});
});
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();
}

View File

@@ -62,7 +62,7 @@ export class LiveQuery<TResponse = unknown> {
private _data: Data<TResponse>;
private _dependencies: Set<string>;
private _listeners: Array<Listener<TResponse>>;
private _supportedSyncTypes: Set<string>;
private _supportedSyncTypes: Set<'applied' | 'success'>;
private _query: Query;
private _onError: (error: Error) => void;
@@ -107,8 +107,8 @@ export class LiveQuery<TResponse = unknown> {
// TODO: error types?
this._supportedSyncTypes = options.onlySync
? new Set<string>(['success'])
: new Set<string>(['applied', 'success']);
? new Set(['success'])
: new Set(['applied', 'success']);
if (onData) {
this.addListener(onData);
@@ -162,15 +162,18 @@ export class LiveQuery<TResponse = unknown> {
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);
}
});
}

View File

@@ -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,13 +47,13 @@ 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)`;
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 = {
@@ -215,7 +215,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(
@@ -252,7 +252,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(
@@ -263,7 +263,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 couldnt apply that change to the database. Please report this as a bug by [opening a Github issue]({{githubIssueLink}}).',
@@ -287,7 +287,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}}).',

View File

@@ -9,7 +9,7 @@ export type Init = typeof init;
export function send<K extends keyof ServerEvents>(
type: K,
args?: ServerEvents[k],
args?: ServerEvents[K],
): void;
export type Send = typeof send;

View File

@@ -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<PayeeEntity['id']>();
const accounts = await db.all('SELECT * FROM accounts WHERE tombstone = 0');
// We need to get all the payees of updated transactions _before_

View File

@@ -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<Handlers> {
events;
events: Emitter<Events>;
handlers: Handlers;
services;
unlistenServices;

View File

@@ -6,6 +6,6 @@ import { createApp } from './app';
// Main app
export const app = createApp<Handlers>();
app.events.on('sync', info => {
connection.send('sync-event', info);
app.events.on('sync', event => {
connection.send('sync-event', event);
});

View File

@@ -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,
});
}
}

View File

@@ -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<string, unknown>;
} & (
| {
type: 'applied';
tables: string[];
data?: Map<string, unknown>;
prevData?: Map<string, unknown>;
}
| {
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;
}

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Add types to loot-core server events.