Compare commits

...

11 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
6494d8ef4e Fix lint errors 2026-03-20 16:28:01 -07:00
Joel Jeremy Marquez
1598b6ae81 Merge remote-tracking branch 'origin/master' into js-proxy 2026-03-20 16:13:26 -07:00
Joel Jeremy Marquez
c057a55a2a Fix tests 2026-03-20 16:13:21 -07:00
Matiss Janis Aboltins
23adf06cb0 Add post-merge hook to auto-install dependencies (#7248)
* [AI] Add post-merge hook to auto-install when yarn.lock changes

Mirrors the existing post-checkout hook behavior: after a git pull or
merge, if yarn.lock changed between ORIG_HEAD and HEAD, automatically
runs yarn install to keep dependencies in sync.

https://claude.ai/code/session_01JHoMhGANLTc1q67s1dUHrt

* Add release notes for PR #7248

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 22:59:05 +00:00
Joel Jeremy Marquez
573238a2e2 Fix tests and typecheck errors 2026-03-20 13:37:36 -07:00
autofix-ci[bot]
07ace07bcc [autofix.ci] apply automated fixes 2026-03-20 19:55:26 +00:00
Joel Jeremy Marquez
069da61644 Separate api app from mainApp 2026-03-20 12:54:33 -07:00
github-actions[bot]
54039b36c6 Add release notes for PR #7246 2026-03-20 19:29:57 +00:00
Joel Jeremy Marquez
ae4e1f9585 Use proxy in server's App framework to be able to call methods more naturally i.e. app.createPayee(...) vs. app.runHandler('createPayee', ...) 2026-03-20 11:56:22 -07:00
Joel Jeremy Marquez
d1f3f3ec10 Refactor app structure and handler invocation. Encapsulate handlers inside app and update all calls to go through the mainApp for consistency. 2026-03-20 10:17:25 -07:00
Joel Jeremy Marquez
269c5a1e48 Use javascript proxy to encapsulate the calls to loot core server via connection package's send 2026-03-19 15:36:36 -07:00
38 changed files with 574 additions and 451 deletions

7
.husky/post-merge Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Run yarn install after pulling/merging (if yarn.lock changed)
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -10,7 +10,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { send, server } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
@@ -176,7 +176,7 @@ export function ManageRules({
let loadedRules = null;
if (payeeId) {
loadedRules = await send('payees-get-rules', {
loadedRules = await server.getPayeeRules({
id: payeeId,
});
} else {

View File

@@ -8,7 +8,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
@@ -58,7 +58,7 @@ export function MobilePayeeEditPage() {
}
try {
await send('payees-batch-change', {
await server.batchChangePayees({
updated: [{ id: payee.id, name: editedPayeeName.trim() }],
});
showUndoNotification({

View File

@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import type { PayeeEntity, RuleEntity } from 'loot-core/types/models';
@@ -52,7 +52,7 @@ export function MobilePayeesPage() {
// View associated rules for the payee
if ((ruleCounts.get(payee.id) ?? 0) > 0) {
try {
const associatedRules: RuleEntity[] = await send('payees-get-rules', {
const associatedRules: RuleEntity[] = await server.getPayeeRules({
id: payee.id,
});
const ruleIds = associatedRules.map(rule => rule.id).join(',');
@@ -88,7 +88,7 @@ export function MobilePayeesPage() {
const handlePayeeDelete = useCallback(
async (payee: PayeeEntity) => {
try {
await send('payees-batch-change', { deleted: [{ id: payee.id }] });
await server.batchChangePayees({ deleted: [{ id: payee.id }] });
showUndoNotification({
message: t('Payee "{{name}}" deleted successfully', {
name: payee.name,

View File

@@ -7,7 +7,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { send, server } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import type { TransObjectLiteral } from 'loot-core/types/util';
@@ -59,7 +59,7 @@ export function MergeUnusedPayeesModal({
const onMerge = useCallback(
async (targetPayee: PayeeEntity) => {
await send('payees-merge', {
await server.mergePayees({
targetId: targetPayee.id,
mergeIds: payees.map(payee => payee.id),
});

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { listen, send } from 'loot-core/platform/client/connection';
import { listen, server } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import type { UndoState } from 'loot-core/server/undo';
import { applyChanges } from 'loot-core/shared/util';
@@ -112,14 +112,14 @@ export function ManagePayeesWithData({
orphanedPayees={orphanedPayees}
initialSelectedIds={initialSelectedIds}
onBatchChange={async (changes: Diff<PayeeEntity>) => {
await send('payees-batch-change', changes);
await server.batchChangePayees(changes);
queryClient.setQueryData(
payeeQueries.listOrphaned().queryKey,
existing => applyChanges(changes, existing ?? []),
);
}}
onMerge={async ([targetId, ...mergeIds]) => {
await send('payees-merge', { targetId, mergeIds });
await server.mergePayees({ targetId, mergeIds });
const targetIdIsOrphan = orphanedPayees
.map(o => o.id)

View File

@@ -1,4 +1,4 @@
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
@@ -68,7 +68,7 @@ export class SendApiLocationClient implements LocationApiClient {
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
return await send('payee-location-create', {
return await server.createPayeeLocation({
payeeId,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
@@ -76,18 +76,18 @@ export class SendApiLocationClient implements LocationApiClient {
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
return await send('payee-locations-get', { payeeId });
return await server.getPayeeLocations({ payeeId });
}
async deleteLocation(locationId: string): Promise<void> {
await send('payee-location-delete', { id: locationId });
await server.deletePayeeLocation({ id: locationId });
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]> {
const result = await send('payees-get-nearby', {
const result = await server.getNearbyPayees({
latitude: coordinates.latitude,
longitude: coordinates.longitude,
maxDistance,

View File

@@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import { locationService } from './location';
@@ -99,7 +99,7 @@ export function useCreatePayeeMutation() {
return useMutation({
mutationFn: async ({ name }: CreatePayeePayload) => {
const id: PayeeEntity['id'] = await send('payee-create', {
const id: PayeeEntity['id'] = await server.createPayee({
name: name.trim(),
});
return id;

View File

@@ -2,7 +2,7 @@ import { queryOptions } from '@tanstack/react-query';
import { t } from 'i18next';
import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import { groupById } from 'loot-core/shared/util';
import type {
AccountEntity,
@@ -21,7 +21,7 @@ export const payeeQueries = {
queryOptions<PayeeEntity[]>({
queryKey: [...payeeQueries.lists()],
queryFn: async () => {
const payees: PayeeEntity[] = (await send('payees-get')) ?? [];
const payees: PayeeEntity[] = (await server.getPayees()) ?? [];
return translatePayees(payees);
},
placeholderData: [],
@@ -32,7 +32,7 @@ export const payeeQueries = {
queryOptions<PayeeEntity[]>({
queryKey: [...payeeQueries.lists(), 'common'],
queryFn: async () => {
const payees: PayeeEntity[] = (await send('common-payees-get')) ?? [];
const payees: PayeeEntity[] = (await server.getCommonPayees()) ?? [];
return translatePayees(payees);
},
placeholderData: [],
@@ -44,7 +44,7 @@ export const payeeQueries = {
queryKey: [...payeeQueries.lists(), 'orphaned'],
queryFn: async () => {
const payees: Pick<PayeeEntity, 'id'>[] =
(await send('payees-get-orphaned')) ?? [];
(await server.getOrphanedPayees()) ?? [];
return payees;
},
placeholderData: [],
@@ -55,7 +55,7 @@ export const payeeQueries = {
queryOptions<Map<PayeeEntity['id'], number>>({
queryKey: [...payeeQueries.lists(), 'ruleCounts'],
queryFn: async () => {
const counts = await send('payees-get-rule-counts');
const counts = await server.getPayeeRuleCounts();
return new Map(Object.entries(counts ?? {}));
},
placeholderData: new Map(),

View File

@@ -2,11 +2,12 @@
import { v4 as uuidv4 } from 'uuid';
import { addTransactions } from '../server/accounts/sync';
import type { App } from '../server/app';
import { aqlQuery } from '../server/aql';
import * as budgetActions from '../server/budget/actions';
import * as budget from '../server/budget/base';
import * as db from '../server/db';
import { runHandler, runMutator } from '../server/mutators';
import { runMutator } from '../server/mutators';
import * as sheet from '../server/sheet';
import { batchMessages, setSyncingMode } from '../server/sync';
import * as monthUtils from '../shared/months';
@@ -84,7 +85,6 @@ function extractCommonThings(
}
async function fillPrimaryChecking(
handlers,
account,
payees: MockPayeeEntity[],
groups: CategoryGroupEntity[],
@@ -255,7 +255,7 @@ async function fillPrimaryChecking(
return addTransactions(account.id, transactions);
}
async function fillChecking(handlers, account, payees, groups) {
async function fillChecking(app, account, payees, groups) {
const { incomePayee, expensePayees, incomeGroup, expenseCategories } =
extractCommonThings(payees, groups);
const numTransactions = integer(20, 40);
@@ -297,13 +297,13 @@ async function fillChecking(handlers, account, payees, groups) {
starting_balance_flag: true,
});
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillInvestment(handlers, account, payees, groups) {
async function fillInvestment(app, account, payees, groups) {
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
const numTransactions = integer(10, 30);
@@ -333,13 +333,13 @@ async function fillInvestment(handlers, account, payees, groups) {
starting_balance_flag: true,
});
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillSavings(handlers, account, payees, groups) {
async function fillSavings(app, account, payees, groups) {
const { incomePayee, expensePayees, incomeGroup, expenseCategories } =
extractCommonThings(payees, groups);
@@ -378,13 +378,13 @@ async function fillSavings(handlers, account, payees, groups) {
starting_balance_flag: true,
});
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillMortgage(handlers, account, payees, groups) {
async function fillMortgage(app, account, payees, groups) {
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
const numTransactions = integer(7, 10);
@@ -415,13 +415,13 @@ async function fillMortgage(handlers, account, payees, groups) {
});
}
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillOther(handlers, account, payees, groups) {
async function fillOther(app, account, payees, groups) {
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
const numTransactions = integer(3, 6);
@@ -453,7 +453,7 @@ async function fillOther(handlers, account, payees, groups) {
});
}
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
@@ -594,7 +594,7 @@ async function createBudget(accounts, payees, groups) {
await sheet.waitOnSpreadsheet();
}
export async function createTestBudget(handlers: Handlers) {
export async function createTestBudget(app: App<Handlers>) {
setSyncingMode('import');
db.execQuery('PRAGMA journal_mode = OFF');
@@ -618,7 +618,7 @@ export async function createTestBudget(handlers: Handlers) {
await runMutator(async () => {
for (const account of accounts) {
account.id = await handlers['account-create'](account);
account.id = await app['account-create'](account);
}
});
@@ -642,7 +642,7 @@ export async function createTestBudget(handlers: Handlers) {
await runMutator(() =>
batchMessages(async () => {
for (const newPayee of newPayees) {
const id = await handlers['payee-create']({ name: newPayee.name });
const id = await app['createPayee']({ name: newPayee.name });
payees.push({
id,
name: newPayee.name,
@@ -690,7 +690,7 @@ export async function createTestBudget(handlers: Handlers) {
await runMutator(async () => {
for (const group of newCategoryGroups) {
const groupId = await handlers['category-group-create']({
const groupId = await app['category-group-create']({
name: group.name,
isIncome: group.is_income,
});
@@ -702,7 +702,7 @@ export async function createTestBudget(handlers: Handlers) {
});
for (const category of group.categories) {
const categoryId = await handlers['category-create']({
const categoryId = await app['category-create']({
...category,
isIncome: category.is_income,
groupId,
@@ -717,7 +717,7 @@ export async function createTestBudget(handlers: Handlers) {
}
});
const allGroups = (await runHandler(handlers['get-categories'])).grouped;
const allGroups = (await app['get-categories']()).grouped;
setSyncingMode('import');
@@ -725,26 +725,26 @@ export async function createTestBudget(handlers: Handlers) {
batchMessages(async () => {
for (const account of accounts) {
if (account.name === 'Bank of America') {
await fillPrimaryChecking(handlers, account, payees, allGroups);
await fillPrimaryChecking(account, payees, allGroups);
} else if (
account.name === 'Capital One Checking' ||
account.name === 'HSBC'
) {
await fillChecking(handlers, account, payees, allGroups);
await fillChecking(app, account, payees, allGroups);
} else if (account.name === 'Ally Savings') {
await fillSavings(handlers, account, payees, allGroups);
await fillSavings(app, account, payees, allGroups);
} else if (
account.name === 'Vanguard 401k' ||
account.name === 'Roth IRA'
) {
await fillInvestment(handlers, account, payees, allGroups);
await fillInvestment(app, account, payees, allGroups);
} else if (account.name === 'Mortgage') {
await fillMortgage(handlers, account, payees, allGroups);
await fillMortgage(app, account, payees, allGroups);
} else if (account.name === 'House Asset') {
await fillOther(handlers, account, payees, allGroups);
await fillOther(app, account, payees, allGroups);
} else {
console.error('Unknown account name for test budget: ', account.name);
await fillChecking(handlers, account, payees, allGroups);
await fillChecking(app, account, payees, allGroups);
}
}
}),
@@ -773,7 +773,7 @@ export async function createTestBudget(handlers: Handlers) {
);
const lastDeposit = results[0];
await runHandler(handlers['transaction-update'], {
await app['transaction-update']({
...lastDeposit,
amount: lastDeposit.amount + -primaryBalance + integer(10000, 20000),
});
@@ -791,7 +791,7 @@ export async function createTestBudget(handlers: Handlers) {
batchMessages(async () => {
const account = accounts.find(acc => acc.name === 'Bank of America');
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Phone bills',
posts_transaction: false,
@@ -822,7 +822,7 @@ export async function createTestBudget(handlers: Handlers) {
],
});
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Internet bill',
posts_transaction: false,
@@ -847,7 +847,7 @@ export async function createTestBudget(handlers: Handlers) {
],
});
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Wedding',
posts_transaction: false,
@@ -868,7 +868,7 @@ export async function createTestBudget(handlers: Handlers) {
],
});
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Utilities',
posts_transaction: false,

View File

@@ -1,7 +1,18 @@
import type { Handlers } from '../../../types/handlers';
import type { ServerEvents } from '../../../types/server-events';
export declare function init(): Promise<unknown>;
/**
* Loot core server proxy.
*/
export type ServerProxy = {
[K in keyof Handlers]: (
args?: Parameters<Handlers[K]>[0],
) => ReturnType<Handlers[K]>;
};
export declare const server: ServerProxy;
export declare function init(): Promise<ServerProxy>;
export type Init = typeof init;
/**

View File

@@ -12,6 +12,7 @@ const listeners = new Map();
let messageQueue = [];
let globalWorker = null;
let initPromise: Promise<T.ServerProxy> | null = null;
class ReconstructedError extends Error {
url: string;
@@ -83,6 +84,28 @@ function handleMessage(msg) {
}
}
export const server: T.ServerProxy = new Proxy({} as T.ServerProxy, {
get(_target, prop: string | symbol) {
if (typeof prop === 'symbol') {
return undefined;
}
// Returning undefined for 'then' prevents the proxy from being
// treated as a thenable when awaited, which would cause Promise
// machinery to call server.then(resolve, reject) with native functions.
if (prop === 'then') {
return undefined;
}
if (!initPromise) {
throw new Error(`Cannot use server proxy before init() has been called`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (args?: any) => send(prop as any, args);
},
});
// Note that this does not support retry. If the worker
// dies, it will permanently be disconnected. That should be OK since
// I don't think a worker should ever die due to a system error.
@@ -107,7 +130,7 @@ function connectWorker(worker, onOpen, onError) {
globalWorker.postMessage({
name: 'client-connected-to-backend',
});
onOpen();
onOpen(server);
} else if (msg.type === 'app-init-failure') {
globalWorker.postMessage({
name: '__app-init-failure-acknowledged',
@@ -149,11 +172,16 @@ function connectWorker(worker, onOpen, onError) {
}
}
export const init: T.Init = async function () {
const worker = await global.Actual.getServerSocket();
return new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
);
export const init: T.Init = function () {
if (!initPromise) {
initPromise = global.Actual.getServerSocket().then(
worker =>
new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
),
);
}
return initPromise;
};
export const send: T.Send = function (

View File

@@ -9,6 +9,29 @@ const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let socketClient = null;
let initPromise: Promise<T.ServerProxy> | null = null;
export const server: T.ServerProxy = new Proxy({} as T.ServerProxy, {
get(_target, prop: string | symbol) {
if (typeof prop === 'symbol') {
return undefined;
}
// Returning undefined for 'then' prevents the proxy from being
// treated as a thenable when awaited, which would cause Promise
// machinery to call server.then(resolve, reject) with native functions.
if (prop === 'then') {
return undefined;
}
if (!initPromise) {
throw new Error(`Cannot use server proxy before init() has been called`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (args?: any) => send(prop as any, args);
},
});
function connectSocket(onOpen) {
global.Actual.ipcConnect(function (client) {
@@ -72,12 +95,15 @@ function connectSocket(onOpen) {
messageQueue = [];
}
onOpen();
onOpen(server);
});
}
export const init: T.Init = async function () {
return new Promise(connectSocket);
export const init: T.Init = function () {
if (!initPromise) {
initPromise = new Promise(connectSocket);
}
return initPromise;
};
export const send: T.Send = function (

View File

@@ -1,9 +1,10 @@
import type { App } from '../../../types/app';
import type { Handlers } from '../../../types/handlers';
import type { ServerEvents } from '../../../types/server-events';
export declare function init(
channel: Window | number, // in electron the port number, in web the worker
handlers: Handlers,
app: App<Handlers>,
): void;
export type Init = typeof init;

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { APIError } from '../../../server/errors';
import { isMutating, runHandler } from '../../../server/mutators';
import { isMutating } from '../../../server/mutators';
import { captureException } from '../../exceptions';
import { logger } from '../log';
@@ -14,12 +14,12 @@ function coerceError(error) {
return { type: 'ServerError', message: error.message, cause: error };
}
export const init: T.Init = function (_socketName, handlers) {
export const init: T.Init = function (_socketName, app) {
process.parentPort.on('message', ({ data }) => {
const { id, name, args, undoTag, catchErrors } = data;
if (handlers[name]) {
runHandler(handlers[name], args, { undoTag, name }).then(
if (app.hasHandler(name)) {
app.runHandler(name, args, { undoTag, name }).then(
result => {
if (catchErrors) {
result = { data: result, error: null };
@@ -30,7 +30,9 @@ export const init: T.Init = function (_socketName, handlers) {
id,
result,
mutated:
isMutating(handlers[name]) && name !== 'undo' && name !== 'redo',
isMutating(app.getHandler(name)) &&
name !== 'undo' &&
name !== 'redo',
undoTag,
});
},

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { APIError } from '../../../server/errors';
import { isMutating, runHandler } from '../../../server/mutators';
import { isMutating } from '../../../server/mutators';
import { captureException } from '../../exceptions';
import { logger } from '../log';
@@ -31,7 +31,7 @@ function coerceError(error) {
return { type: 'ServerError', message: error.message, cause: error };
}
export const init: T.Init = function (serverChn, handlers) {
export const init: T.Init = function (serverChn, app) {
const serverChannel = serverChn as Window;
getGlobalObject().__globalServerChannel = serverChannel;
@@ -54,14 +54,14 @@ export const init: T.Init = function (serverChn, handlers) {
const { id, name, args, undoTag, catchErrors } = msg;
if (handlers[name]) {
runHandler(handlers[name], args, { undoTag, name }).then(
if (app.hasHandler(name)) {
app.runHandler(name, args, { undoTag, name }).then(
result => {
serverChannel.postMessage({
type: 'reply',
id,
result: catchErrors ? { data: result, error: null } : result,
mutated: isMutating(handlers[name]),
mutated: isMutating(app.getHandler(name)),
undoTag,
});
},

View File

@@ -10,7 +10,7 @@ vi.mock('./sync', async () => ({
syncAccount: vi.fn(),
}));
const simpleFinBatchSyncHandler = app.handlers['simplefin-batch-sync'];
const simpleFinBatchSyncHandler = app['simplefin-batch-sync'];
function insertBank(bank: { id: string; bank_id: string; name: string }) {
db.runQuery(

View File

@@ -28,7 +28,7 @@ import {
PostError,
TransactionError,
} from '../errors';
import { app as mainApp } from '../main-app';
import { mainApp } from '../main';
import { mutator } from '../mutators';
import { get, post } from '../post';
import { getServer } from '../server-config';
@@ -495,7 +495,7 @@ async function closeAccount({
);
}
await mainApp.handlers['transaction-add']({
await mainApp['transaction-add']({
id: uuidv4(),
payee: transferPayee.id,
amount: -balance,

View File

@@ -1,36 +1,43 @@
import { getBankSyncError } from '../shared/errors';
import type { ServerHandlers } from '../types/server-handlers';
import { installAPI } from './api';
vi.mock('../shared/errors', () => ({
getBankSyncError: vi.fn(error => `Bank sync error: ${error}`),
}));
describe('API handlers', () => {
const handlers = installAPI({} as unknown as ServerHandlers);
// @ts-strict-ignore
import { app as apiApp } from './api';
import { mainApp } from './main';
describe('API app', () => {
describe('api/bank-sync', () => {
it('should sync a single account when accountId is provided', async () => {
handlers['accounts-bank-sync'] = vi
.fn()
.mockResolvedValue({ errors: [] });
await handlers['api/bank-sync']({ accountId: 'account1' });
expect(handlers['accounts-bank-sync']).toHaveBeenCalledWith({
ids: ['account1'],
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle errors in non batch sync', async () => {
handlers['accounts-bank-sync'] = vi.fn().mockResolvedValue({
errors: ['connection-failed'],
});
it('should sync a single account when accountId is provided', async () => {
vi.spyOn(mainApp, 'runHandler').mockImplementation(
async (name: string) => {
if (name === 'accounts-bank-sync') return { errors: [] };
throw new Error(`Unexpected handler: ${name}`);
},
);
await apiApp['api/bank-sync']({ accountId: 'account1' });
expect(mainApp.runHandler.bind(mainApp)).toHaveBeenCalledWith(
'accounts-bank-sync',
{
ids: ['account1'],
},
);
});
it('should throw an error when bank sync fails', async () => {
vi.spyOn(mainApp, 'runHandler').mockImplementation(
async (name: string) => {
if (name === 'accounts-bank-sync') {
return { errors: [{ message: 'connection-failed' }] };
}
throw new Error(`Unexpected handler: ${name}`);
},
);
await expect(
handlers['api/bank-sync']({ accountId: 'account2' }),
).rejects.toThrow('Bank sync error: connection-failed');
expect(getBankSyncError).toHaveBeenCalledWith('connection-failed');
apiApp['api/bank-sync']({ accountId: 'account2' }),
).rejects.toThrow('connection-failed');
});
});
});

View File

@@ -17,13 +17,12 @@ import {
updateTransaction,
} from '../shared/transactions';
import { integerToAmount } from '../shared/util';
import type { Handlers } from '../types/handlers';
import type { ApiHandlers } from '../types/api-handlers';
import type {
AccountEntity,
CategoryGroupEntity,
ScheduleEntity,
} from '../types/models';
import type { ServerHandlers } from '../types/server-handlers';
import { addTransactions } from './accounts/sync';
import {
@@ -37,11 +36,13 @@ import {
tagModel,
} from './api-models';
import type { AmountOPType, APIScheduleEntity } from './api-models';
import { createApp } from './app';
import { aqlQuery } from './aql';
import * as cloudStorage from './cloud-storage';
import type { RemoteFile } from './cloud-storage';
import * as db from './db';
import { APIError } from './errors';
import { mainApp } from './main';
import { runMutator } from './mutators';
import * as prefs from './prefs';
import * as sheet from './sheet';
@@ -82,7 +83,7 @@ function withMutation<Params extends Array<unknown>, ReturnType>(
};
}
let handlers = {} as unknown as Handlers;
const handlers = {} as ApiHandlers;
async function validateMonth(month) {
if (!month.match(/^\d{4}-\d{2}$/)) {
@@ -90,7 +91,7 @@ async function validateMonth(month) {
}
if (!IMPORT_MODE) {
const { start, end } = await handlers['get-budget-bounds']();
const { start, end } = await mainApp['get-budget-bounds']();
const range = monthUtils.range(start, end);
if (!range.includes(month)) {
throw APIError('No budget exists for month: ' + month);
@@ -162,7 +163,7 @@ handlers['api/load-budget'] = async function ({ id }) {
if (currentId !== id) {
connection.send('start-load');
const { error } = await handlers['load-budget']({ id });
const { error } = await mainApp['load-budget']({ id });
if (!error) {
connection.send('finish-load');
@@ -177,16 +178,16 @@ handlers['api/load-budget'] = async function ({ id }) {
handlers['api/download-budget'] = async function ({ syncId, password }) {
const { id: currentId } = prefs.getPrefs() || {};
if (currentId) {
await handlers['close-budget']();
await mainApp['close-budget']();
}
const budgets = await handlers['get-budgets']();
const budgets = await mainApp['get-budgets']();
const localBudget = budgets.find(b => b.groupId === syncId);
let remoteBudget: RemoteFile;
// Load a remote file if we could not find the file locally
if (!localBudget) {
const files = await handlers['get-remote-files']();
const files = await mainApp['get-remote-files']();
if (!files) {
throw new Error('Could not get remote files');
}
@@ -210,7 +211,7 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
);
}
const result = await handlers['key-test']({
const result = await mainApp['key-test']({
cloudFileId: remoteBudget ? remoteBudget.fileId : localBudget.cloudFileId,
password,
});
@@ -221,8 +222,8 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
// Sync the local budget file
if (localBudget) {
await handlers['load-budget']({ id: localBudget.id });
const result = await handlers['sync-budget']();
await mainApp['load-budget']({ id: localBudget.id });
const result = await mainApp['sync-budget']();
if (result.error) {
throw new Error(getSyncError(result.error.reason, localBudget.id));
}
@@ -230,19 +231,19 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
}
// Download the remote file (no need to perform a sync as the file will already be up-to-date)
const result = await handlers['download-budget']({
const result = await mainApp['download-budget']({
cloudFileId: remoteBudget.fileId,
});
if (result.error) {
logger.log('Full error details', result.error);
throw new Error(getDownloadError(result.error));
}
await handlers['load-budget']({ id: result.id });
await mainApp['load-budget']({ id: result.id });
};
handlers['api/get-budgets'] = async function () {
const budgets = await handlers['get-budgets']();
const files = (await handlers['get-remote-files']()) || [];
const budgets = await mainApp['get-budgets']();
const files = (await mainApp['get-remote-files']()) || [];
return [
...budgets.map(file => budgetModel.toExternal(file)),
...files.map(file => remoteFileModel.toExternal(file)).filter(file => file),
@@ -251,7 +252,7 @@ handlers['api/get-budgets'] = async function () {
handlers['api/sync'] = async function () {
const { id } = prefs.getPrefs();
const result = await handlers['sync-budget']();
const result = await mainApp['sync-budget']();
if (result.error) {
throw new Error(getSyncError(result.error.reason, id));
}
@@ -262,13 +263,13 @@ handlers['api/bank-sync'] = async function (args) {
const allErrors = [];
if (!batchSync) {
const { errors } = await handlers['accounts-bank-sync']({
const { errors } = await mainApp['accounts-bank-sync']({
ids: [args.accountId],
});
allErrors.push(...errors);
} else {
const accountsData = await handlers['accounts-get']();
const accountsData = await mainApp['accounts-get']();
const accountIdsToSync = accountsData.map(a => a.id);
const simpleFinAccounts = accountsData.filter(
a => a.account_sync_source === 'simpleFin',
@@ -276,14 +277,14 @@ handlers['api/bank-sync'] = async function (args) {
const simpleFinAccountIds = simpleFinAccounts.map(a => a.id);
if (simpleFinAccounts.length > 1) {
const res = await handlers['simplefin-batch-sync']({
const res = await mainApp['simplefin-batch-sync']({
ids: simpleFinAccountIds,
});
res.forEach(a => allErrors.push(...a.res.errors));
}
const { errors } = await handlers['accounts-bank-sync']({
const { errors } = await mainApp['accounts-bank-sync']({
ids: accountIdsToSync.filter(a => !simpleFinAccountIds.includes(a)),
});
@@ -298,10 +299,10 @@ handlers['api/bank-sync'] = async function (args) {
handlers['api/start-import'] = async function ({ budgetName }) {
// Notify UI to close budget
await handlers['close-budget']();
await mainApp['close-budget']();
// Create the budget
await handlers['create-budget']({ budgetName, avoidUpload: true });
await mainApp['create-budget']({ budgetName, avoidUpload: true });
// Clear out the default expense categories
db.runQuery('DELETE FROM categories WHERE is_income = 0');
@@ -323,10 +324,10 @@ handlers['api/finish-import'] = async function () {
// the spreadsheet, but we can't just recreate the spreadsheet
// either; there is other internal state that isn't created
const { id } = prefs.getPrefs();
await handlers['close-budget']();
await handlers['load-budget']({ id });
await mainApp['close-budget']();
await mainApp['load-budget']({ id });
await handlers['get-budget-bounds']();
await mainApp['get-budget-bounds']();
await sheet.waitOnSpreadsheet();
await cloudStorage.upload().catch(() => {
@@ -343,8 +344,8 @@ handlers['api/abort-import'] = async function () {
const { id } = prefs.getPrefs();
await handlers['close-budget']();
await handlers['delete-budget']({ id });
await mainApp['close-budget']();
await mainApp['delete-budget']({ id });
connection.send('show-budgets');
}
@@ -358,7 +359,7 @@ handlers['api/query'] = async function ({ query }) {
handlers['api/budget-months'] = async function () {
checkFileOpen();
const { start, end } = await handlers['get-budget-bounds']();
const { start, end } = await mainApp['get-budget-bounds']();
return monthUtils.range(start, end);
};
@@ -428,7 +429,7 @@ handlers['api/budget-set-amount'] = withMutation(async function ({
amount,
}) {
checkFileOpen();
return handlers['budget/budget-amount']({
return mainApp['budget/budget-amount']({
month,
category: categoryId,
amount,
@@ -443,7 +444,7 @@ handlers['api/budget-set-carryover'] = withMutation(async function ({
checkFileOpen();
await validateMonth(month);
await validateExpenseCategory('budget-set-carryover', categoryId);
return handlers['budget/set-carryover']({
return mainApp['budget/set-carryover']({
startMonth: month,
category: categoryId,
flag,
@@ -459,7 +460,7 @@ handlers['api/budget-hold-for-next-month'] = withMutation(async function ({
if (amount <= 0) {
throw APIError('Amount to hold needs to be greater than 0');
}
return handlers['budget/hold-for-next-month']({
return mainApp['budget/hold-for-next-month']({
month,
amount,
});
@@ -468,7 +469,7 @@ handlers['api/budget-hold-for-next-month'] = withMutation(async function ({
handlers['api/budget-reset-hold'] = withMutation(async function ({ month }) {
checkFileOpen();
await validateMonth(month);
return handlers['budget/reset-hold']({ month });
return mainApp['budget/reset-hold']({ month });
});
handlers['api/transactions-export'] = async function ({
@@ -478,7 +479,7 @@ handlers['api/transactions-export'] = async function ({
accounts,
}) {
checkFileOpen();
return handlers['transactions-export']({
return mainApp['transactions-export']({
transactions,
categoryGroups,
payees,
@@ -493,7 +494,7 @@ handlers['api/transactions-import'] = withMutation(async function ({
opts,
}) {
checkFileOpen();
return handlers['transactions-import']({
return mainApp['transactions-import']({
accountId,
transactions,
isPreview,
@@ -551,7 +552,7 @@ handlers['api/transaction-update'] = withMutation(async function ({
}
const { diff } = updateTransaction(transactions, { id, ...fields });
return handlers['transactions-batch-update'](diff)['updated'];
return mainApp['transactions-batch-update'](diff)['updated'];
});
handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
@@ -566,12 +567,12 @@ handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
}
const { diff } = deleteTransaction(transactions, id);
return handlers['transactions-batch-update'](diff)['deleted'];
return mainApp['transactions-batch-update'](diff)['deleted'];
});
handlers['api/accounts-get'] = async function () {
checkFileOpen();
const accounts: AccountEntity[] = await handlers['accounts-get']();
const accounts: AccountEntity[] = await mainApp['accounts-get']();
return accounts.map(account => accountModel.toExternal(account));
};
@@ -580,7 +581,7 @@ handlers['api/account-create'] = withMutation(async function ({
initialBalance = null,
}) {
checkFileOpen();
return handlers['account-create']({
return mainApp['account-create']({
name: account.name,
offBudget: account.offbudget,
closed: account.closed,
@@ -592,7 +593,7 @@ handlers['api/account-create'] = withMutation(async function ({
handlers['api/account-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
return db.updateAccount({ id, ...accountModel.fromExternal(fields) });
await mainApp['account-update']({ id, ...accountModel.fromExternal(fields) });
});
handlers['api/account-close'] = withMutation(async function ({
@@ -601,7 +602,7 @@ handlers['api/account-close'] = withMutation(async function ({
transferCategoryId,
}) {
checkFileOpen();
return handlers['account-close']({
return mainApp['account-close']({
id,
transferAccountId,
categoryId: transferCategoryId,
@@ -610,12 +611,12 @@ handlers['api/account-close'] = withMutation(async function ({
handlers['api/account-reopen'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['account-reopen']({ id });
return mainApp['account-reopen']({ id });
});
handlers['api/account-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['account-close']({ id, forced: true });
return mainApp['account-close']({ id, forced: true });
});
handlers['api/account-balance'] = withMutation(async function ({
@@ -623,14 +624,14 @@ handlers['api/account-balance'] = withMutation(async function ({
cutoff = new Date(),
}) {
checkFileOpen();
return handlers['account-balance']({ id, cutoff });
return mainApp['account-balance']({ id, cutoff });
});
handlers['api/categories-get'] = async function ({
grouped,
}: { grouped? } = {}) {
checkFileOpen();
const result = await handlers['get-categories']();
const result = await mainApp['get-categories']();
return grouped
? result.grouped.map(group => categoryGroupModel.toExternal(group))
: result.list.map(category => categoryModel.toExternal(category));
@@ -638,7 +639,7 @@ handlers['api/categories-get'] = async function ({
handlers['api/category-groups-get'] = async function () {
checkFileOpen();
const groups = await handlers['get-category-groups']();
const groups = await mainApp['get-category-groups']();
return groups.map(group => categoryGroupModel.toExternal(group));
};
@@ -646,7 +647,7 @@ handlers['api/category-group-create'] = withMutation(async function ({
group,
}) {
checkFileOpen();
return handlers['category-group-create']({
return mainApp['category-group-create']({
name: group.name,
hidden: group.hidden,
});
@@ -657,7 +658,7 @@ handlers['api/category-group-update'] = withMutation(async function ({
fields,
}) {
checkFileOpen();
return handlers['category-group-update']({
return mainApp['category-group-update']({
id,
...categoryGroupModel.fromExternal(fields),
});
@@ -668,7 +669,7 @@ handlers['api/category-group-delete'] = withMutation(async function ({
transferCategoryId,
}) {
checkFileOpen();
return handlers['category-group-delete']({
return mainApp['category-group-delete']({
id,
transferId: transferCategoryId,
});
@@ -676,7 +677,7 @@ handlers['api/category-group-delete'] = withMutation(async function ({
handlers['api/category-create'] = withMutation(async function ({ category }) {
checkFileOpen();
return handlers['category-create']({
return mainApp['category-create']({
name: category.name,
groupId: category.group_id,
isIncome: category.is_income,
@@ -686,7 +687,7 @@ handlers['api/category-create'] = withMutation(async function ({ category }) {
handlers['api/category-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
return handlers['category-update']({
return mainApp['category-update']({
id,
...categoryModel.fromExternal(fields),
});
@@ -697,7 +698,7 @@ handlers['api/category-delete'] = withMutation(async function ({
transferCategoryId,
}) {
checkFileOpen();
return handlers['category-delete']({
return mainApp['category-delete']({
id,
transferId: transferCategoryId,
});
@@ -705,31 +706,31 @@ handlers['api/category-delete'] = withMutation(async function ({
handlers['api/common-payees-get'] = async function () {
checkFileOpen();
const payees = await handlers['common-payees-get']();
const payees = await mainApp.getCommonPayees();
return payees.map(payee => payeeModel.toExternal(payee));
};
handlers['api/payees-get'] = async function () {
checkFileOpen();
const payees = await handlers['payees-get']();
const payees = await mainApp.getPayees();
return payees.map(payee => payeeModel.toExternal(payee));
};
handlers['api/payee-create'] = withMutation(async function ({ payee }) {
checkFileOpen();
return handlers['payee-create']({ name: payee.name });
return mainApp.createPayee({ name: payee.name });
});
handlers['api/payee-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
return handlers['payees-batch-change']({
return mainApp.batchChangePayees({
updated: [{ id, ...payeeModel.fromExternal(fields) }],
});
});
handlers['api/payee-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['payees-batch-change']({ deleted: [{ id }] });
return mainApp.batchChangePayees({ deleted: [{ id }] });
});
handlers['api/payees-merge'] = withMutation(async function ({
@@ -737,18 +738,18 @@ handlers['api/payees-merge'] = withMutation(async function ({
mergeIds,
}) {
checkFileOpen();
return handlers['payees-merge']({ targetId, mergeIds });
return mainApp.mergePayees({ targetId, mergeIds });
});
handlers['api/tags-get'] = async function () {
checkFileOpen();
const tags = await handlers['tags-get']();
const tags = await mainApp['tags-get']();
return tags.map(tag => tagModel.toExternal(tag));
};
handlers['api/tag-create'] = withMutation(async function ({ tag }) {
checkFileOpen();
const result = await handlers['tags-create']({
const result = await mainApp['tags-create']({
tag: tag.tag,
color: tag.color,
description: tag.description,
@@ -758,12 +759,12 @@ handlers['api/tag-create'] = withMutation(async function ({ tag }) {
handlers['api/tag-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
await handlers['tags-update']({ id, ...tagModel.fromExternal(fields) });
await mainApp['tags-update']({ id, ...tagModel.fromExternal(fields) });
});
handlers['api/tag-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
await handlers['tags-delete']({ id });
await mainApp['tags-delete']({ id });
});
handlers['api/payee-location-create'] = withMutation(async function ({
@@ -772,17 +773,17 @@ handlers['api/payee-location-create'] = withMutation(async function ({
longitude,
}) {
checkFileOpen();
return handlers['payee-location-create']({ payeeId, latitude, longitude });
return mainApp.createPayeeLocation({ payeeId, latitude, longitude });
});
handlers['api/payee-locations-get'] = async function ({ payeeId }) {
checkFileOpen();
return handlers['payee-locations-get']({ payeeId });
return mainApp.getPayeeLocations({ payeeId });
};
handlers['api/payee-location-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['payee-location-delete']({ id });
return mainApp.deletePayeeLocation({ id });
});
handlers['api/payees-get-nearby'] = async function ({
@@ -791,22 +792,22 @@ handlers['api/payees-get-nearby'] = async function ({
maxDistance,
}) {
checkFileOpen();
return handlers['payees-get-nearby']({ latitude, longitude, maxDistance });
return mainApp.getNearbyPayees({ latitude, longitude, maxDistance });
};
handlers['api/rules-get'] = async function () {
checkFileOpen();
return handlers['rules-get']();
return mainApp['rules-get']();
};
handlers['api/payee-rules-get'] = async function ({ id }) {
checkFileOpen();
return handlers['payees-get-rules']({ id });
return mainApp.getPayeeRules({ id });
};
handlers['api/rule-create'] = withMutation(async function ({ rule }) {
checkFileOpen();
const addedRule = await handlers['rule-add'](rule);
const addedRule = await mainApp['rule-add'](rule);
if ('error' in addedRule) {
throw APIError('Failed creating a new rule', addedRule.error);
@@ -817,7 +818,7 @@ handlers['api/rule-create'] = withMutation(async function ({ rule }) {
handlers['api/rule-update'] = withMutation(async function ({ rule }) {
checkFileOpen();
const updatedRule = await handlers['rule-update'](rule);
const updatedRule = await mainApp['rule-update'](rule);
if ('error' in updatedRule) {
throw APIError('Failed updating the rule', updatedRule.error);
@@ -828,7 +829,7 @@ handlers['api/rule-update'] = withMutation(async function ({ rule }) {
handlers['api/rule-delete'] = withMutation(async function (id) {
checkFileOpen();
return handlers['rule-delete'](id);
return mainApp['rule-delete'](id);
});
handlers['api/schedules-get'] = async function () {
@@ -847,7 +848,7 @@ handlers['api/schedule-create'] = withMutation(async function (
name: internalSchedule.name,
posts_transaction: internalSchedule.posts_transaction,
};
return handlers['schedule/create']({
return mainApp['schedule/create']({
schedule: partialSchedule,
conditions: internalSchedule._conditions,
});
@@ -981,7 +982,7 @@ handlers['api/schedule-update'] = withMutation(async function ({
}
if (conditionsUpdated) {
return handlers['schedule/update']({
return mainApp['schedule/update']({
schedule: {
id: sched.id,
posts_transaction: sched.posts_transaction,
@@ -997,7 +998,7 @@ handlers['api/schedule-update'] = withMutation(async function ({
handlers['api/schedule-delete'] = withMutation(async function (id: string) {
checkFileOpen();
return handlers['schedule/delete']({ id });
return mainApp['schedule/delete']({ id });
});
handlers['api/get-id-by-name'] = async function ({ type, name }) {
@@ -1020,11 +1021,7 @@ handlers['api/get-id-by-name'] = async function ({ type, name }) {
handlers['api/get-server-version'] = async function () {
checkFileOpen();
return handlers['get-server-version']();
return mainApp['get-server-version']();
};
export function installAPI(serverHandlers: ServerHandlers) {
const merged = Object.assign({}, serverHandlers, handlers);
handlers = merged as Handlers;
return merged;
}
export const app = createApp(handlers);

View File

@@ -5,6 +5,8 @@ import type { Emitter } from 'mitt';
import { captureException } from '../platform/exceptions';
import type { ServerEvents } from '../types/server-events';
import { runHandler as mutatorRunHandler } from './mutators';
// 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
@@ -18,22 +20,32 @@ type Events = {
type UnlistenService = () => void;
type Service = () => UnlistenService;
class App<Handlers> {
events: Emitter<Events>;
handlers: Handlers;
services: Service[];
unlistenServices: UnlistenService[];
export class App<THandlers> {
private handlers: THandlers;
private services: Service[];
private unlistenServices: UnlistenService[];
constructor() {
this.handlers = {} as Handlers;
readonly events: Emitter<Events>;
constructor(handlers?: THandlers) {
this.handlers = {} as THandlers;
this.services = [];
this.events = mitt<Events>();
this.unlistenServices = [];
if (handlers) {
for (const [name, func] of Object.entries(handlers)) {
this.method(
name as string & keyof THandlers,
func as THandlers[string & keyof THandlers],
);
}
}
}
method<Name extends string & keyof Handlers>(
method<Name extends string & keyof THandlers>(
name: Name,
func: Handlers[Name],
func: THandlers[Name],
) {
if (this.handlers[name] != null) {
throw new Error(
@@ -50,7 +62,7 @@ class App<Handlers> {
combine(...apps) {
for (const app of apps) {
Object.keys(app.handlers).forEach(name => {
this.method(name as string & keyof Handlers, app.handlers[name]);
this.method(name as string & keyof THandlers, app.handlers[name]);
});
app.services.forEach(service => {
@@ -84,8 +96,50 @@ class App<Handlers> {
});
this.unlistenServices = [];
}
getHandler<T extends keyof THandlers>(name: T): THandlers[T] {
return this.handlers[name];
}
hasHandler<T extends keyof THandlers>(name: T): boolean {
return this.getHandler(name) != null;
}
async runHandler<T extends keyof THandlers>(
name: T,
args?: THandlers[T] extends (...a: infer A) => unknown ? A[0] : never,
options?: Parameters<typeof mutatorRunHandler>[2],
): Promise<
THandlers[T] extends (...a: infer _A) => Promise<infer R> ? R : never
> {
const handler = this.handlers[name];
if (!handler) {
throw new Error(`No handler for method: ${String(name)}`);
}
return mutatorRunHandler(
handler as Parameters<typeof mutatorRunHandler>[0],
args,
options,
) as Promise<
THandlers[T] extends (...a: infer _A) => Promise<infer R> ? R : never
>;
}
}
export function createApp<T>() {
return new App<T>();
export function createApp<THandlers>(
handlers?: THandlers,
): App<THandlers> & THandlers {
const app = new App<THandlers>(handlers);
return new Proxy(app, {
get(target, prop, receiver) {
if (prop in target) {
return Reflect.get(target, prop, receiver);
}
return new Proxy(target.runHandler.bind(target), {
apply(boundFn, _thisArg, [args]) {
return boundFn(prop as keyof THandlers, args);
},
});
},
}) as App<THandlers> & THandlers;
}

View File

@@ -16,7 +16,7 @@ import * as db from '../db';
import * as mappings from '../db/mappings';
import { handleBudgetImport } from '../importers';
import type { ImportableBudgetType } from '../importers';
import { app as mainApp } from '../main-app';
import { mainApp } from '../main';
import { mutator } from '../mutators';
import * as prefs from '../prefs';
import { getServer } from '../server-config';
@@ -453,7 +453,7 @@ async function createBudget({
}
if (testMode) {
await createTestBudget(mainApp.handlers);
await createTestBudget(mainApp);
}
return {};

View File

@@ -2,14 +2,14 @@
import * as fs from '../../platform/server/fs';
import * as sqlite from '../../platform/server/sqlite';
import * as cloudStorage from '../cloud-storage';
import { handlers } from '../main';
import { mainApp } from '../main';
import { waitOnSpreadsheet } from '../sheet';
export async function importActual(_filepath: string, buffer: Buffer) {
// Importing Actual files is a special case because we can directly
// write down the files, but because it doesn't go through the API
// layer we need to duplicate some of the workflow
await handlers['close-budget']();
await mainApp['close-budget']();
let id;
try {
@@ -40,8 +40,8 @@ export async function importActual(_filepath: string, buffer: Buffer) {
// Load the budget, force everything to be computed, and try
// to upload it as a cloud file
await handlers['load-budget']({ id });
await handlers['get-budget-bounds']();
await mainApp['load-budget']({ id });
await mainApp['get-budget-bounds']();
await waitOnSpreadsheet();
await cloudStorage.upload().catch(() => {
// Ignore errors

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { logger } from '../../platform/server/log';
import { handlers } from '../main';
import { mainApp } from '../main';
import { importActual } from './actual';
import * as YNAB4 from './ynab4';
@@ -42,17 +42,17 @@ export async function handleBudgetImport(
}
try {
await handlers['api/start-import']({ budgetName });
await mainApp['api/start-import']({ budgetName });
} catch (e) {
logger.error('failed to start import', e);
return { error: 'unknown' };
}
await importer.doImport(data);
} catch (e) {
await handlers['api/abort-import']();
await mainApp['api/abort-import']();
logger.error('failed to run import', e);
return { error: 'unknown' };
}
await handlers['api/finish-import']();
await mainApp['api/finish-import']();
}

View File

@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../platform/server/log';
import * as monthUtils from '../../shared/months';
import { amountToInteger, groupBy, sortByKey } from '../../shared/util';
import { send } from '../main-app';
import { mainApp } from '../main';
import type * as YNAB4 from './ynab4-types';
@@ -21,7 +21,7 @@ async function importAccounts(
return Promise.all(
accounts.map(async account => {
if (!account.isTombstone) {
const id = await send('api/account-create', {
const id = await mainApp['api/account-create']({
account: {
name: account.accountName,
offbudget: account.onBudget ? false : true,
@@ -48,7 +48,7 @@ async function importCategories(
masterCategory.subCategories &&
masterCategory.subCategories.some(cat => !cat.isTombstone)
) {
const id = await send('api/category-group-create', {
const id = await mainApp['api/category-group-create']({
group: {
name: masterCategory.name,
is_income: false,
@@ -56,7 +56,7 @@ async function importCategories(
});
entityIdMap.set(masterCategory.entityId, id);
if (masterCategory.note) {
void send('notes-save', {
void mainApp['notes-save']({
id,
note: masterCategory.note,
});
@@ -89,7 +89,7 @@ async function importCategories(
categoryName = categoryNameParts.join('/').trim();
}
const id = await send('api/category-create', {
const id = await mainApp['api/category-create']({
category: {
name: categoryName,
group_id: entityIdMap.get(category.masterCategoryId),
@@ -97,7 +97,7 @@ async function importCategories(
});
entityIdMap.set(category.entityId, id);
if (category.note) {
void send('notes-save', {
void mainApp['notes-save']({
id,
note: category.note,
});
@@ -116,7 +116,7 @@ async function importPayees(
) {
for (const payee of data.payees) {
if (!payee.isTombstone) {
const id = await send('api/payee-create', {
const id = await mainApp['api/payee-create']({
payee: {
name: payee.name,
transfer_acct: entityIdMap.get(payee.targetAccountId) || null,
@@ -134,14 +134,14 @@ async function importTransactions(
data: YNAB4.YFull,
entityIdMap: Map<string, string>,
) {
const categories = await send('api/categories-get', {
const categories = await mainApp['api/categories-get']({
grouped: false,
});
const incomeCategoryId: string = categories.find(
cat => cat.name === 'Income',
).id;
const accounts = await send('api/accounts-get');
const payees = await send('api/payees-get');
const accounts = await mainApp['api/accounts-get']();
const payees = await mainApp['api/payees-get']();
function getCategory(id: string) {
if (id == null || id === 'Category/__Split__') {
@@ -245,7 +245,7 @@ async function importTransactions(
})
.filter(x => x);
await send('api/transactions-add', {
await mainApp['api/transactions-add']({
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
@@ -291,7 +291,7 @@ async function importBudgets(
) {
const budgets = sortByKey(data.monthlyBudgets, 'month');
await send('api/batch-budget-start');
await mainApp['api/batch-budget-start']();
try {
for (const budget of budgets) {
const filled = fillInBudgets(
@@ -308,20 +308,20 @@ async function importBudgets(
return;
}
await send('api/budget-set-amount', {
await mainApp['api/budget-set-amount']({
month,
categoryId: catId,
amount,
});
if (catBudget.overspendingHandling === 'AffectsBuffer') {
await send('api/budget-set-carryover', {
await mainApp['api/budget-set-carryover']({
month,
categoryId: catId,
flag: false,
});
} else if (catBudget.overspendingHandling === 'Confined') {
await send('api/budget-set-carryover', {
await mainApp['api/budget-set-carryover']({
month,
categoryId: catId,
flag: true,
@@ -331,7 +331,7 @@ async function importBudgets(
);
}
} finally {
await send('api/batch-budget-end');
await mainApp['api/batch-budget-end']();
}
}

View File

@@ -6,7 +6,8 @@ import * as monthUtils from '../../shared/months';
import { q } from '../../shared/query';
import { groupBy, sortByKey } from '../../shared/util';
import type { RecurConfig, RecurPattern, RuleEntity } from '../../types/models';
import { send } from '../main-app';
import { aqlQuery } from '../aql';
import { mainApp } from '../main';
import { ruleModel } from '../transactions/transaction-rules';
import type {
@@ -271,7 +272,7 @@ function importAccounts(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.accounts.map(async account => {
if (!account.deleted) {
const id = await send('api/account-create', {
const id = await mainApp['api/account-create']({
account: {
name: account.name,
offbudget: account.on_budget ? false : true,
@@ -291,7 +292,7 @@ async function importCategories(
// Hidden categories are put in its own group by YNAB,
// so it's already handled.
const categories = await send('api/categories-get', {
const categories = await mainApp['api/categories-get']({
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
@@ -336,7 +337,7 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await send('api/category-group-create', {
const id = await mainApp['api/category-group-create']({
group: { ...params, name },
});
return { id, name };
@@ -361,7 +362,7 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await send('api/category-create', {
const id = await mainApp['api/category-create']({
category: { ...params, name },
});
return { id, name };
@@ -393,7 +394,7 @@ async function importCategories(
groupId = createdGroup.id;
entityIdMap.set(group.id, groupId);
if (group.note) {
void send('notes-save', {
void mainApp['notes-save']({
id: groupId,
note: group.note,
});
@@ -434,7 +435,7 @@ async function importCategories(
});
entityIdMap.set(cat.id, createdCategory.id);
if (cat.note) {
void send('notes-save', {
void mainApp['notes-save']({
id: createdCategory.id,
note: cat.note,
});
@@ -451,7 +452,7 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.payees.map(async payee => {
if (!payee.deleted) {
const id = await send('api/payee-create', {
const id = await mainApp['api/payee-create']({
payee: { name: payee.name },
});
entityIdMap.set(payee.id, id);
@@ -498,7 +499,7 @@ async function importPayeeLocations(
try {
// Create the payee location in Actual
await send('payee-location-create', {
await mainApp.createPayeeLocation({
payeeId: actualPayeeId,
latitude,
longitude,
@@ -557,7 +558,7 @@ async function importFlagsAsTags(
await Promise.all(
[...tagsToCreate.entries()].map(async ([tag, color]) => {
await send('tags-create', {
await mainApp['tags-create']({
tag,
color,
description: 'Imported from YNAB',
@@ -571,8 +572,8 @@ async function importTransactions(
entityIdMap: Map<string, string>,
flagNameConflicts: Set<string>,
) {
const payees = await send('api/payees-get');
const categories = await send('api/categories-get', {
const payees = await mainApp['api/payees-get']();
const categories = await mainApp['api/categories-get']({
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
@@ -837,7 +838,7 @@ async function importTransactions(
})
.filter(x => x);
await send('api/transactions-add', {
await mainApp['api/transactions-add']({
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
@@ -861,7 +862,7 @@ async function importScheduledTransactions(
return;
}
const payees = await send('api/payees-get');
const payees = await mainApp['api/payees-get']();
const payeesByTransferAcct = payees
.filter(payee => payee?.transfer_acct)
.map(payee => [payee.transfer_acct, payee] as [string, Payee]);
@@ -884,7 +885,7 @@ async function importScheduledTransactions(
while (true) {
try {
return await send('api/schedule-create', {
return await mainApp['api/schedule-create']({
...params,
name: params.name,
});
@@ -902,19 +903,16 @@ async function importScheduledTransactions(
async function getRuleForSchedule(
scheduleId: string,
): Promise<RuleEntity | null> {
const { data: ruleId } = (await send('api/query', {
query: q('schedules')
.filter({ id: scheduleId })
.calculate('rule')
.serialize(),
})) as { data: string | null };
const { data: ruleId } = (await aqlQuery(
q('schedules').filter({ id: scheduleId }).calculate('rule').serialize(),
)) as { data: string | null };
if (!ruleId) {
return null;
}
const { data: ruleData } = (await send('api/query', {
query: q('rules').filter({ id: ruleId }).select('*').serialize(),
})) as { data: Array<Record<string, unknown>> };
const { data: ruleData } = (await aqlQuery(
q('rules').filter({ id: ruleId }).select('*').serialize(),
)) as { data: Array<Record<string, unknown>> };
const ruleRow = ruleData?.[0];
if (!ruleRow) {
return null;
@@ -973,7 +971,7 @@ async function importScheduledTransactions(
value: scheduleNotes,
});
await send('api/rule-update', {
await mainApp['api/rule-update']({
rule: buildRuleUpdate(rule, actions),
});
}
@@ -1008,7 +1006,7 @@ async function importScheduledTransactions(
value: categoryId,
});
await send('api/rule-update', {
await mainApp['api/rule-update']({
rule: buildRuleUpdate(rule, actions),
});
}
@@ -1087,7 +1085,7 @@ async function importScheduledTransactions(
}
});
await send('api/rule-update', {
await mainApp['api/rule-update']({
rule: buildRuleUpdate(rule, actions),
});
}
@@ -1114,7 +1112,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
'Credit Card Payments',
);
await send('api/batch-budget-start');
await mainApp['api/batch-budget-start']();
try {
for (const budget of budgets) {
const month = monthUtils.monthFromDate(budget.month);
@@ -1132,7 +1130,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
return;
}
await send('api/budget-set-amount', {
await mainApp['api/budget-set-amount']({
month,
categoryId: catId,
amount,
@@ -1141,7 +1139,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
);
}
} finally {
await send('api/batch-budget-end');
await mainApp['api/batch-budget-end']();
}
}

View File

@@ -1,25 +0,0 @@
import * as connection from '../platform/server/connection';
import type { Handlers } from '../types/handlers';
import { createApp } from './app';
import { runHandler } from './mutators';
// Main app
export const app = createApp<Handlers>();
app.events.on('sync', event => {
connection.send('sync-event', event);
});
/**
* Run a handler by name (server-side). Same API shape as the client connection's send.
* Used by server code that needs to invoke handlers directly, e.g. importers.
*/
export async function send<K extends keyof Handlers>(
name: K,
args?: Parameters<Handlers[K]>[0],
): Promise<Awaited<ReturnType<Handlers[K]>>> {
return runHandler(app.handlers[name], args, { name }) as Promise<
Awaited<ReturnType<Handlers[K]>>
>;
}

View File

@@ -10,7 +10,7 @@ import * as monthUtils from '../shared/months';
import * as budgetActions from './budget/actions';
import * as budget from './budget/base';
import * as db from './db';
import { handlers } from './main';
import { mainApp } from './main';
import {
disableGlobalMutations,
enableGlobalMutations,
@@ -28,7 +28,7 @@ beforeEach(async () => {
});
afterEach(async () => {
await runHandler(handlers['close-budget']);
await mainApp['close-budget']();
connection.resetEvents();
enableGlobalMutations();
global.currentMonth = null;
@@ -72,7 +72,7 @@ describe('Budgets', () => {
'SELECT * FROM messages_clock',
);
const { error } = await runHandler(handlers['load-budget'], {
const { error } = await mainApp['load-budget']({
id: 'test-budget',
});
expect(error).toBe(undefined);
@@ -92,7 +92,7 @@ describe('Budgets', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => null);
const { error } = await runHandler(handlers['load-budget'], {
const { error } = await mainApp['load-budget']({
id: 'test-budget',
});
// There should be an error and the budget should be unloaded
@@ -128,7 +128,7 @@ describe('Accounts', () => {
});
const id = 'test-transfer';
await runHandler(handlers['transaction-add'], {
await mainApp['transaction-add']({
id,
account: 'one',
amount: 5000,
@@ -140,7 +140,7 @@ describe('Accounts', () => {
);
let transaction = await db.getTransaction(id);
await runHandler(handlers['transaction-update'], {
await mainApp['transaction-update']({
...(await db.getTransaction(id)),
payee: 'transfer-three',
date: '2017-01-03',
@@ -150,7 +150,7 @@ describe('Accounts', () => {
);
transaction = await db.getTransaction(id);
await runHandler(handlers['transaction-delete'], transaction);
await mainApp['transaction-delete'](transaction);
differ.expectToMatchDiff(
await db.all<db.DbTransaction>('SELECT * FROM transactions'),
);
@@ -172,7 +172,7 @@ describe('Budget', () => {
await db.insertCategory({ name: 'bar', cat_group: 'group1' });
});
let bounds = await runHandler(handlers['get-budget-bounds']);
let bounds = await mainApp['get-budget-bounds']();
expect(bounds.start).toBe('2016-10');
expect(bounds.end).toBe('2018-01');
expect(spreadsheet.meta().createdMonths).toMatchSnapshot();
@@ -181,7 +181,7 @@ describe('Budget', () => {
// current earliest budget to test if it creates the necessary
// budgets for the earlier months
db.runQuery("INSERT INTO accounts (id, name) VALUES ('one', 'boa')");
await runHandler(handlers['transaction-add'], {
await mainApp['transaction-add']({
id: uuidv4(),
date: '2016-05-06',
amount: 50,
@@ -192,7 +192,7 @@ describe('Budget', () => {
// budgets for the months in the future
global.currentMonth = '2017-02';
bounds = await runHandler(handlers['get-budget-bounds']);
bounds = await mainApp['get-budget-bounds']();
expect(bounds.start).toBe('2016-02');
expect(bounds.end).toBe('2018-02');
expect(spreadsheet.meta().createdMonths).toMatchSnapshot();
@@ -230,19 +230,19 @@ describe('Budget', () => {
db.insertCategoryGroup({ id: 'group1', name: 'group1' }),
);
categories = [
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'foo',
groupId: 'group1',
}),
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'bar',
groupId: 'group1',
}),
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'baz',
groupId: 'group1',
}),
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'biz',
groupId: 'group1',
}),
@@ -259,14 +259,14 @@ describe('Budget', () => {
};
// Test insertions
let changed = await captureChangedCells(() =>
runHandler(handlers['transaction-add'], trans),
mainApp['transaction-add'](trans),
);
expect(
changed.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)),
).toMatchSnapshot();
// Test updates
changed = await captureChangedCells(async () => {
await runHandler(handlers['transaction-update'], {
await mainApp['transaction-update']({
...(await db.getTransaction(trans.id)),
amount: 7000,
});
@@ -276,7 +276,7 @@ describe('Budget', () => {
).toMatchSnapshot();
// Test deletions
changed = await captureChangedCells(async () => {
await runHandler(handlers['transaction-delete'], { id: trans.id });
await mainApp['transaction-delete']({ id: trans.id });
});
expect(
changed.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)),
@@ -298,7 +298,7 @@ describe('Categories', () => {
expect(categories.length).toBe(2);
expect(categories.find(cat => cat.name === 'foo')).not.toBeNull();
expect(categories.find(cat => cat.name === 'bar')).not.toBeNull();
await runHandler(handlers['category-delete'], { id: 'foo' });
await mainApp['category-delete']({ id: 'foo' });
categories = await db.getCategories();
expect(categories.length).toBe(1);
@@ -355,7 +355,7 @@ describe('Categories', () => {
let trans = await db.getTransaction(transId);
expect(trans.category).toBe('foo');
await runHandler(handlers['category-delete'], {
await mainApp['category-delete']({
id: 'foo',
transferId: 'bar',
});
@@ -371,7 +371,7 @@ describe('Categories', () => {
// Transfering an income category to an expense just doesn't make
// sense. Make sure this doesn't do anything.
await expect(
runHandler(handlers['category-delete'], {
mainApp['category-delete']({
id: 'income1',
transferId: 'bar',
}),
@@ -381,7 +381,7 @@ describe('Categories', () => {
expect(categories.find(cat => cat.id === 'income1')).toBeDefined();
// Make sure you can delete income categories
await runHandler(handlers['category-delete'], {
await mainApp['category-delete']({
id: 'income1',
transferId: 'income2',
});

View File

@@ -6,12 +6,15 @@ import * as fs from '../platform/server/fs';
import { logger, setVerboseMode } from '../platform/server/log';
import * as sqlite from '../platform/server/sqlite';
import { q } from '../shared/query';
import type { QueryState } from '../shared/query';
import { amountToInteger, integerToAmount } from '../shared/util';
import type { Handlers } from '../types/handlers';
import type { ApiHandlers } from '../types/api-handlers';
import type { Handlers, ServerHandlers } from '../types/handlers';
import { app as accountsApp } from './accounts/app';
import { app as adminApp } from './admin/app';
import { installAPI } from './api';
import { app as apiApp } from './api';
import { createApp } from './app';
import { aqlQuery } from './aql';
import { app as authApp } from './auth/app';
import { app as budgetApp } from './budget/app';
@@ -21,8 +24,7 @@ import * as db from './db';
import * as encryption from './encryption';
import { app as encryptionApp } from './encryption/app';
import { app as filtersApp } from './filters/app';
import { app } from './main-app';
import { mutator, runHandler } from './mutators';
import { mutator } from './mutators';
import { app as notesApp } from './notes/app';
import { app as payeesApp } from './payees/app';
import { get } from './post';
@@ -41,38 +43,24 @@ import { app as transactionsApp } from './transactions/app';
import * as rules from './transactions/transaction-rules';
import { redo, undo } from './undo';
// handlers
// need to work around the type system here because the object
// is /currently/ empty but we promise to fill it in later
export let handlers = {} as unknown as Handlers;
handlers['undo'] = mutator(async function () {
return undo();
});
handlers['redo'] = mutator(function () {
return redo();
});
handlers['make-filters-from-conditions'] = async function ({
async function makeFiltersFromConditions({
conditions,
applySpecialCases,
applySpecialCases = undefined,
}) {
return rules.conditionsToAQL(conditions, { applySpecialCases });
};
}
handlers['query'] = async function (query) {
async function query(query) {
if (query['table'] == null) {
throw new Error('query has no table, did you forgot to call `.serialize`?');
}
return aqlQuery(query);
};
}
handlers['get-server-version'] = async function () {
async function getServerVersion() {
if (!getServer()) {
return { error: 'no-server' };
return { error: 'no-server' as const };
}
let version;
@@ -80,19 +68,19 @@ handlers['get-server-version'] = async function () {
const res = await get(getServer().BASE_SERVER + '/info');
const info = JSON.parse(res);
version = info.build.version;
version = info.build.version as string;
} catch {
return { error: 'network-failure' };
return { error: 'network-failure' as const };
}
return { version };
};
}
handlers['get-server-url'] = async function () {
async function getServerUrl() {
return getServer() && getServer().BASE_SERVER;
};
}
handlers['set-server-url'] = async function ({ url, validate = true }) {
async function setServerUrl({ url, validate = true }) {
if (url == null) {
await asyncStorage.removeItem('user-token');
} else {
@@ -100,7 +88,7 @@ handlers['set-server-url'] = async function ({ url, validate = true }) {
if (validate) {
// Validate the server is running
const result = await runHandler(handlers['subscribe-needs-bootstrap'], {
const result = await mainApp['subscribe-needs-bootstrap']({
url,
});
if ('error' in result) {
@@ -113,20 +101,60 @@ handlers['set-server-url'] = async function ({ url, validate = true }) {
await asyncStorage.setItem('did-bootstrap', true);
setServer(url);
return {};
};
}
handlers['app-focused'] = async function () {
async function appFocused() {
if (prefs.getPrefs() && prefs.getPrefs().id) {
// First we sync
void fullSync();
}
}
export type MiscHandlers = {
undo: () => Promise<void>;
redo: () => Promise<void>;
'make-filters-from-conditions': (arg: {
conditions: unknown;
applySpecialCases?: boolean;
}) => Promise<{ filters: unknown[] }>;
// oxlint-disable-next-line typescript/no-explicit-any
query: (query: QueryState) => Promise<{ data: any; dependencies: string[] }>;
'get-server-version': () => Promise<
{ error: 'no-server' } | { error: 'network-failure' } | { version: string }
>;
'get-server-url': () => Promise<string | null>;
'set-server-url': (arg: {
url: string;
validate?: boolean;
}) => Promise<{ error?: string }>;
'app-focused': () => Promise<void>;
};
handlers = installAPI(handlers) as Handlers;
const miscApp = createApp<MiscHandlers>({
undo: mutator(undo),
redo: mutator(redo),
'make-filters-from-conditions': makeFiltersFromConditions,
query,
'get-server-version': getServerVersion,
'get-server-url': getServerUrl,
'set-server-url': setServerUrl,
'app-focused': appFocused,
});
// A hack for now until we clean up everything
app.handlers = handlers;
app.combine(
const serverApp = createApp<ServerHandlers>();
serverApp.events.on('sync', event => {
connection.send('sync-event', event);
});
serverApp.combine(
miscApp,
authApp,
schedulesApp,
budgetApp,
@@ -148,6 +176,9 @@ app.combine(
tagsApp,
);
export const mainApp = createApp<Handlers>();
mainApp.combine(apiApp, serverApp);
export function getDefaultDocumentDir() {
return fs.join(process.env.ACTUAL_DOCUMENT_DIR, 'Actual');
}
@@ -209,17 +240,7 @@ export async function initApp(isDev, socketName) {
}
setServer(url);
connection.init(socketName, app.handlers);
// Allow running DB queries locally
global.$query = aqlQuery;
global.$q = q;
if (isDev) {
global.$send = (name, args) => runHandler(app.handlers[name], args);
global.$db = db;
global.$setSyncingMode = setSyncingMode;
}
connection.init(socketName, mainApp);
}
type BaseInitConfig = {
@@ -278,25 +299,25 @@ export async function init(config: InitConfig) {
if ('sessionToken' in config && config.sessionToken) {
// Session token authentication
await runHandler(handlers['subscribe-set-token'], {
await mainApp['subscribe-set-token']({
token: config.sessionToken,
});
// Validate the token
const user = await runHandler(handlers['subscribe-get-user'], undefined);
const user = await mainApp['subscribe-get-user']();
if (!user || user.tokenExpired === true) {
// Clear invalid token
await runHandler(handlers['subscribe-set-token'], { token: '' });
await mainApp['subscribe-set-token']({ token: '' });
throw new Error(
'Authentication failed: invalid or expired session token',
);
}
if (user.offline === true) {
// Clear token since we can't validate
await runHandler(handlers['subscribe-set-token'], { token: '' });
await mainApp['subscribe-set-token']({ token: '' });
throw new Error('Authentication failed: server offline or unreachable');
}
} else if ('password' in config && config.password) {
const result = await runHandler(handlers['subscribe-sign-in'], {
const result = await mainApp['subscribe-sign-in']({
password: config.password,
});
if (result?.error) {
@@ -308,7 +329,7 @@ export async function init(config: InitConfig) {
// access to the server, we are doing things locally
setServer(null);
app.events.on('load-budget', () => {
mainApp.events.on('load-budget', () => {
setSyncingMode('offline');
});
}
@@ -321,14 +342,14 @@ export async function init(config: InitConfig) {
export const lib = {
getDataDir: fs.getDataDir,
sendMessage: (msg, args) => connection.send(msg, args),
send: async <K extends keyof Handlers, T extends Handlers[K]>(
send: async <K extends keyof Handlers>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> => {
const res = await runHandler(app.handlers[name], args);
return res;
args?: Parameters<Handlers[K]>[0],
): Promise<Awaited<ReturnType<Handlers[K]>>> => {
const res = await mainApp.runHandler(name, args);
return res as Awaited<ReturnType<Handlers[K]>>;
},
on: (name, func) => app.events.on(name, func),
on: (name, func) => mainApp.events.on(name, func),
q,
db,
amountToInteger,

View File

@@ -1,7 +1,10 @@
// @ts-strict-ignore
import { captureBreadcrumb, captureException } from '../platform/exceptions';
import { sequential } from '../shared/async';
import type { HandlerFunctions, Handlers } from '../types/handlers';
type Handler<TArgs extends unknown[] = unknown[], TReturn = unknown> = (
...args: TArgs
) => TReturn;
const runningMethods = new Set();
@@ -11,7 +14,9 @@ let globalMutationsEnabled = false;
let _latestHandlerNames = [];
export function mutator<T extends HandlerFunctions>(handler: T): T {
export function mutator<TArgs extends unknown[], TReturn>(
handler: Handler<TArgs, TReturn>,
): Handler<TArgs, TReturn> {
mutatingMethods.set(handler, true);
return handler;
}
@@ -38,11 +43,11 @@ function wait(time) {
return new Promise(resolve => setTimeout(resolve, time));
}
export async function runHandler<T extends Handlers[keyof Handlers]>(
handler: T,
args?: Parameters<T>[0],
export async function runHandler<TArgs extends unknown[], TReturn>(
handler: Handler<TArgs, Promise<TReturn>>,
args?: TArgs[0],
{ undoTag, name }: { undoTag?; name? } = {},
): Promise<ReturnType<T>> {
): Promise<TReturn> {
// For debug reasons, track the latest handlers that have been
// called
_latestHandlerNames.push(name);
@@ -50,10 +55,16 @@ export async function runHandler<T extends Handlers[keyof Handlers]>(
_latestHandlerNames = _latestHandlerNames.slice(-5);
}
const invokeHandler = () =>
handler(...((args !== undefined ? [args] : []) as TArgs));
if (mutatingMethods.has(handler)) {
return runMutator(() => handler(args), { undoTag }) as Promise<
ReturnType<T>
>;
// If already inside a mutator, call directly to avoid deadlocking the
// sequential queue.
if (currentContext !== null) {
return invokeHandler();
}
return runMutator(invokeHandler, { undoTag });
}
// When closing a file, it clears out all global state for the file. That
@@ -64,12 +75,12 @@ export async function runHandler<T extends Handlers[keyof Handlers]>(
await flushRunningMethods();
}
const promise = handler(args);
const promise = invokeHandler();
runningMethods.add(promise);
void promise.then(() => {
runningMethods.delete(promise);
});
return promise as Promise<ReturnType<T>>;
return promise;
}
// These are useful for tests. Only use them in tests.

View File

@@ -14,44 +14,42 @@ import { batchMessages } from '../sync';
import * as rules from '../transactions/transaction-rules';
import { undoable } from '../undo';
export type PayeesHandlers = {
'payee-create': typeof createPayee;
'common-payees-get': typeof getCommonPayees;
'payees-get': typeof getPayees;
'payees-get-orphaned': typeof getOrphanedPayees;
'payees-get-rule-counts': typeof getPayeeRuleCounts;
'payees-merge': typeof mergePayees;
'payees-batch-change': typeof batchChangePayees;
'payees-check-orphaned': typeof checkOrphanedPayees;
'payees-get-rules': typeof getPayeeRules;
'payee-location-create': typeof createPayeeLocation;
'payee-locations-get': typeof getPayeeLocations;
'payee-location-delete': typeof deletePayeeLocation;
'payees-get-nearby': typeof getNearbyPayees;
export type PayeeHandlers = {
createPayee: typeof createPayee;
getCommonPayees: typeof getCommonPayees;
getPayees: typeof getPayees;
getOrphanedPayees: typeof getOrphanedPayees;
getPayeeRuleCounts: typeof getPayeeRuleCounts;
mergePayees: typeof mergePayees;
batchChangePayees: typeof batchChangePayees;
checkOrphanedPayees: typeof checkOrphanedPayees;
getPayeeRules: typeof getPayeeRules;
createPayeeLocation: typeof createPayeeLocation;
getPayeeLocations: typeof getPayeeLocations;
deletePayeeLocation: typeof deletePayeeLocation;
getNearbyPayees: typeof getNearbyPayees;
};
export const app = createApp<PayeesHandlers>();
app.method('payee-create', mutator(undoable(createPayee)));
app.method('common-payees-get', getCommonPayees);
app.method('payees-get', getPayees);
app.method('payees-get-orphaned', getOrphanedPayees);
app.method('payees-get-rule-counts', getPayeeRuleCounts);
app.method(
'payees-merge',
mutator(
export const app = createApp<PayeeHandlers>({
createPayee: mutator(undoable(createPayee)),
batchChangePayees: mutator(undoable(batchChangePayees)),
createPayeeLocation: mutator(createPayeeLocation),
deletePayeeLocation: mutator(deletePayeeLocation),
mergePayees: mutator(
undoable(mergePayees, args => ({
mergeIds: args.mergeIds,
targetId: args.targetId,
})),
),
);
app.method('payees-batch-change', mutator(undoable(batchChangePayees)));
app.method('payees-check-orphaned', checkOrphanedPayees);
app.method('payees-get-rules', getPayeeRules);
app.method('payee-location-create', mutator(createPayeeLocation));
app.method('payee-locations-get', getPayeeLocations);
app.method('payee-location-delete', mutator(deletePayeeLocation));
app.method('payees-get-nearby', getNearbyPayees);
getCommonPayees,
getPayees,
getOrphanedPayees,
getPayeeRuleCounts,
checkOrphanedPayees,
getPayeeRules,
getPayeeLocations,
getNearbyPayees,
});
async function createPayee({ name }: { name: PayeeEntity['name'] }) {
return db.insertPayee({ name });

View File

@@ -17,7 +17,7 @@ import type { MetadataPrefs } from '../../types/prefs';
import { setType as setBudgetType, triggerBudgetChanges } from '../budget/base';
import * as db from '../db';
import { PostError, SyncError } from '../errors';
import { app } from '../main-app';
import { mainApp } from '../main';
import { runMutator } from '../mutators';
import { postBinary } from '../post';
import * as prefs from '../prefs';
@@ -410,7 +410,7 @@ export const applyMessages = sequential(async (messages: Message[]) => {
_syncListeners.forEach(func => func(oldData, newData));
const tables = getTablesFromMessages(messages.filter(msg => !msg.old));
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'applied',
tables,
data: newData,
@@ -444,16 +444,16 @@ async function errorHandler(e: Error) {
// couldn't apply, which doesn't make any sense. Must be a bug
// in the code. Send a specific error type for it for a custom
// message.
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'apply-failure',
meta: e.meta,
});
} else {
app.events.emit('sync', { type: 'error', meta: e.meta });
mainApp.events.emit('sync', { type: 'error', meta: e.meta });
}
} else if (e instanceof Timestamp.ClockDriftError) {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'clock-drift',
meta: { message: e.message },
@@ -574,7 +574,7 @@ export const fullSync = once(async function (): Promise<
| { messages: Message[] }
| { error: { message: string; reason: string; meta: unknown } }
> {
app.events.emit('sync', { type: 'start' });
mainApp.events.emit('sync', { type: 'start' });
let messages;
try {
@@ -586,13 +586,13 @@ export const fullSync = once(async function (): Promise<
if (e.reason === 'out-of-sync') {
captureException(e);
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'out-of-sync',
meta: e.meta,
});
} else if (e.reason === 'invalid-schema') {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'invalid-schema',
meta: e.meta,
@@ -601,36 +601,36 @@ export const fullSync = once(async function (): Promise<
e.reason === 'decrypt-failure' ||
e.reason === 'encrypt-failure'
) {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: e.reason,
meta: e.meta,
});
} else if (e.reason === 'clock-drift') {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'clock-drift',
meta: e.meta,
});
} else {
app.events.emit('sync', { type: 'error', meta: e.meta });
mainApp.events.emit('sync', { type: 'error', meta: e.meta });
}
} else if (e instanceof PostError) {
logger.log(e);
if (e.reason === 'unauthorized') {
app.events.emit('sync', { type: 'unauthorized' });
mainApp.events.emit('sync', { type: 'unauthorized' });
// Set the user into read-only mode
void asyncStorage.setItem('readOnly', 'true');
} else if (e.reason === 'network-failure') {
app.events.emit('sync', { type: 'error', subtype: 'network' });
mainApp.events.emit('sync', { type: 'error', subtype: 'network' });
} else {
app.events.emit('sync', { type: 'error', subtype: e.reason });
mainApp.events.emit('sync', { type: 'error', subtype: e.reason });
}
} else {
captureException(e);
// TODO: Send the message to the client and allow them to expand & view it
app.events.emit('sync', { type: 'error' });
mainApp.events.emit('sync', { type: 'error' });
}
return { error: { message: e.message, reason: e.reason, meta: e.meta } };
@@ -638,7 +638,7 @@ export const fullSync = once(async function (): Promise<
const tables = getTablesFromMessages(messages);
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'success',
tables,
syncDisabled: checkSyncingMode('disabled'),

View File

@@ -1,12 +1,12 @@
import { v4 as uuidv4 } from 'uuid';
import * as fs from '../../platform/server/fs';
import { handlers } from '../main';
import { mainApp } from '../main';
export async function uniqueBudgetName(
initialName: string = 'My Finances',
): Promise<string> {
const budgets = await handlers['get-budgets']();
const budgets = await mainApp['get-budgets']();
let idx = 1;
// If there is a conflict, keep appending an index until there is no

View File

@@ -0,0 +1 @@
export { App } from '../server/app';

View File

@@ -6,8 +6,9 @@ import type { BudgetFileHandlers } from '../server/budgetfiles/app';
import type { DashboardHandlers } from '../server/dashboard/app';
import type { EncryptionHandlers } from '../server/encryption/app';
import type { FiltersHandlers } from '../server/filters/app';
import type { MiscHandlers } from '../server/main';
import type { NotesHandlers } from '../server/notes/app';
import type { PayeesHandlers } from '../server/payees/app';
import type { PayeeHandlers } from '../server/payees/app';
import type { PreferencesHandlers } from '../server/preferences/app';
import type { ReportsHandlers } from '../server/reports/app';
import type { RulesHandlers } from '../server/rules/app';
@@ -19,10 +20,8 @@ import type { ToolsHandlers } from '../server/tools/app';
import type { TransactionHandlers } from '../server/transactions/app';
import type { ApiHandlers } from './api-handlers';
import type { ServerHandlers } from './server-handlers';
export type Handlers = {} & ServerHandlers &
ApiHandlers &
export type ServerHandlers = MiscHandlers &
BudgetHandlers &
DashboardHandlers &
FiltersHandlers &
@@ -35,7 +34,7 @@ export type Handlers = {} & ServerHandlers &
AdminHandlers &
ToolsHandlers &
AccountHandlers &
PayeesHandlers &
PayeeHandlers &
SpreadsheetHandlers &
SyncHandlers &
BudgetFileHandlers &
@@ -43,4 +42,6 @@ export type Handlers = {} & ServerHandlers &
TagsHandlers &
AuthHandlers;
export type Handlers = {} & ServerHandlers & ApiHandlers;
export type HandlerFunctions = Handlers[keyof Handlers];

View File

@@ -1,27 +0,0 @@
import type { QueryState } from '../shared/query';
export type ServerHandlers = {
undo: () => Promise<void>;
redo: () => Promise<void>;
'make-filters-from-conditions': (arg: {
conditions: unknown;
applySpecialCases?: boolean;
}) => Promise<{ filters: unknown[] }>;
// oxlint-disable-next-line typescript/no-explicit-any
query: (query: QueryState) => Promise<{ data: any; dependencies: string[] }>;
'get-server-version': () => Promise<
{ error: 'no-server' } | { error: 'network-failure' } | { version: string }
>;
'get-server-url': () => Promise<string | null>;
'set-server-url': (arg: {
url: string;
validate?: boolean;
}) => Promise<{ error?: string }>;
'app-focused': () => Promise<void>;
};

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Refactor client-server communication to use typed server proxy methods for improved clarity and safety.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.