Refactor API integration in loot-core by removing api-helpers and directly invoking handlers. Update typecheck script in api package to include strict checks, and refine TypeScript configurations across multiple packages for improved type safety and build processes.

This commit is contained in:
Matiss Janis Aboltins
2026-02-21 22:22:40 +00:00
parent 0619198f50
commit cd7663ecfd
6 changed files with 121 additions and 188 deletions

View File

@@ -17,7 +17,7 @@
"build": "yarn run clean && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run clean && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types",
"typecheck": "yarn build && tsc --noEmit"
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",

View File

@@ -9,7 +9,8 @@
"noEmit": false,
"declaration": true,
"outDir": "dist",
"declarationDir": "@types"
"declarationDir": "@types",
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"include": ["."],
"exclude": [

View File

@@ -27,9 +27,6 @@ export default defineConfig({
'.api.js',
'.api.ts',
'.api.tsx',
'.electron.js',
'.electron.ts',
'.electron.tsx',
'.js',
'.ts',
'.tsx',

View File

@@ -1,122 +0,0 @@
// @ts-strict-ignore
// Local API helper module for importers
// This provides the same interface as @actual-app/api/methods but uses handlers directly
// to avoid cyclic dependency between loot-core and @actual-app/api
import type { QueryState } from '../../shared/query';
import type { Handlers } from '../../types/handlers';
import type { ImportTransactionEntity, RuleEntity } from '../../types/models';
import type {
APIAccountEntity,
APICategoryEntity,
APICategoryGroupEntity,
APIPayeeEntity,
APIScheduleEntity,
} from '../api-models';
import { app } from '../main-app';
import { runHandler } from '../mutators';
// Send function that calls handlers directly
export function send<K extends keyof Handlers>(
name: K,
args?: Parameters<Handlers[K]>[0],
): Promise<Awaited<ReturnType<Handlers[K]>>> {
return runHandler(app.handlers[name], args) as Promise<
Awaited<ReturnType<Handlers[K]>>
>;
}
// API methods used by importers
export async function createAccount(
account: Omit<APIAccountEntity, 'id'>,
initialBalance?: number,
) {
return send('api/account-create', { account, initialBalance });
}
export async function getAccounts() {
return send('api/accounts-get');
}
export async function getCategories() {
return send('api/categories-get', { grouped: false });
}
export async function createCategoryGroup(
group: Omit<APICategoryGroupEntity, 'id'>,
) {
return send('api/category-group-create', { group });
}
export async function createCategory(category: Omit<APICategoryEntity, 'id'>) {
return send('api/category-create', { category });
}
export async function createPayee(payee: Omit<APIPayeeEntity, 'id'>) {
return send('api/payee-create', { payee });
}
export async function getPayees() {
return send('api/payees-get');
}
export async function addTransactions(
accountId: APIAccountEntity['id'],
transactions: Omit<ImportTransactionEntity, 'account'>[],
{
learnCategories = false,
runTransfers = false,
}: { learnCategories?: boolean; runTransfers?: boolean } = {},
) {
return send('api/transactions-add', {
accountId,
transactions,
learnCategories,
runTransfers,
});
}
export async function batchBudgetUpdates(func: () => Promise<void>) {
await send('api/batch-budget-start');
try {
await func();
} finally {
await send('api/batch-budget-end');
}
}
export async function setBudgetAmount(
month: string,
categoryId: APICategoryEntity['id'],
value: number,
) {
return send('api/budget-set-amount', { month, categoryId, amount: value });
}
export async function setBudgetCarryover(
month: string,
categoryId: APICategoryEntity['id'],
flag: boolean,
) {
return send('api/budget-set-carryover', { month, categoryId, flag });
}
export async function createSchedule(
schedule: Omit<APIScheduleEntity, 'id'>,
): Promise<string> {
return send('api/schedule-create', schedule);
}
export function aqlQuery(
query: QueryState | { serialize(): QueryState },
): Promise<unknown> {
const queryState =
typeof (query as { serialize?: () => QueryState }).serialize === 'function'
? (query as { serialize(): QueryState }).serialize()
: (query as QueryState);
return send('api/query', { query: queryState });
}
export async function updateRule(rule: RuleEntity): Promise<RuleEntity> {
return send('api/rule-update', { rule });
}

View File

@@ -8,10 +8,10 @@ import { amountToInteger, groupBy, sortByKey } from '../../shared/util';
// @ts-strict-ignore
// This is a special usage of the API because this package is embedded
// into Actual itself. We use local API helpers that call handlers directly
// to avoid cyclic dependency between loot-core and @actual-app/api
import { send } from './api-helpers';
import * as actual from './api-helpers';
// into Actual itself. We call handlers directly to avoid cyclic dependency
// between loot-core and @actual-app/api
import { app } from '../main-app';
import { runHandler } from '../mutators';
import type * as YNAB4 from './ynab4-types';
// Importer
@@ -25,10 +25,12 @@ async function importAccounts(
return Promise.all(
accounts.map(async account => {
if (!account.isTombstone) {
const id = await actual.createAccount({
name: account.accountName,
offbudget: account.onBudget ? false : true,
closed: account.hidden ? true : false,
const id = await runHandler(app.handlers['api/account-create'], {
account: {
name: account.accountName,
offbudget: account.onBudget ? false : true,
closed: account.hidden ? true : false,
},
});
entityIdMap.set(account.entityId, id);
}
@@ -50,13 +52,15 @@ async function importCategories(
masterCategory.subCategories &&
masterCategory.subCategories.some(cat => !cat.isTombstone)
) {
const id = await actual.createCategoryGroup({
name: masterCategory.name,
is_income: false,
const id = await runHandler(app.handlers['api/category-group-create'], {
group: {
name: masterCategory.name,
is_income: false,
},
});
entityIdMap.set(masterCategory.entityId, id);
if (masterCategory.note) {
send('notes-save', { id, note: masterCategory.note });
void runHandler(app.handlers['notes-save'], { id, note: masterCategory.note });
}
if (masterCategory.subCategories) {
@@ -86,13 +90,15 @@ async function importCategories(
categoryName = categoryNameParts.join('/').trim();
}
const id = await actual.createCategory({
name: categoryName,
group_id: entityIdMap.get(category.masterCategoryId),
const id = await runHandler(app.handlers['api/category-create'], {
category: {
name: categoryName,
group_id: entityIdMap.get(category.masterCategoryId),
},
});
entityIdMap.set(category.entityId, id);
if (category.note) {
send('notes-save', { id, note: category.note });
void runHandler(app.handlers['notes-save'], { id, note: category.note });
}
}
}
@@ -108,9 +114,11 @@ async function importPayees(
) {
for (const payee of data.payees) {
if (!payee.isTombstone) {
const id = await actual.createPayee({
name: payee.name,
transfer_acct: entityIdMap.get(payee.targetAccountId) || null,
const id = await runHandler(app.handlers['api/payee-create'], {
payee: {
name: payee.name,
transfer_acct: entityIdMap.get(payee.targetAccountId) || null,
},
});
// TODO: import payee rules
@@ -124,12 +132,14 @@ async function importTransactions(
data: YNAB4.YFull,
entityIdMap: Map<string, string>,
) {
const categories = await actual.getCategories();
const categories = await runHandler(app.handlers['api/categories-get'], {
grouped: false,
});
const incomeCategoryId: string = categories.find(
cat => cat.name === 'Income',
).id;
const accounts = await actual.getAccounts();
const payees = await actual.getPayees();
const accounts = await runHandler(app.handlers['api/accounts-get']);
const payees = await runHandler(app.handlers['api/payees-get']);
function getCategory(id: string) {
if (id == null || id === 'Category/__Split__') {
@@ -233,8 +243,11 @@ async function importTransactions(
})
.filter(x => x);
await actual.addTransactions(entityIdMap.get(accountId), toImport, {
await runHandler(app.handlers['api/transactions-add'], {
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
runTransfers: false,
});
}),
);
@@ -276,7 +289,8 @@ async function importBudgets(
) {
const budgets = sortByKey(data.monthlyBudgets, 'month');
await actual.batchBudgetUpdates(async () => {
await runHandler(app.handlers['api/batch-budget-start']);
try {
for (const budget of budgets) {
const filled = fillInBudgets(
data,
@@ -292,17 +306,31 @@ async function importBudgets(
return;
}
await actual.setBudgetAmount(month, catId, amount);
await runHandler(app.handlers['api/budget-set-amount'], {
month,
categoryId: catId,
amount,
});
if (catBudget.overspendingHandling === 'AffectsBuffer') {
await actual.setBudgetCarryover(month, catId, false);
await runHandler(app.handlers['api/budget-set-carryover'], {
month,
categoryId: catId,
flag: false,
});
} else if (catBudget.overspendingHandling === 'Confined') {
await actual.setBudgetCarryover(month, catId, true);
await runHandler(app.handlers['api/budget-set-carryover'], {
month,
categoryId: catId,
flag: true,
});
}
}),
);
}
});
} finally {
await runHandler(app.handlers['api/batch-budget-end']);
}
}
function estimateRecentness(str: string) {

View File

@@ -9,10 +9,10 @@ import { ruleModel } from '../transactions/transaction-rules';
// @ts-strict-ignore
// This is a special usage of the API because this package is embedded
// into Actual itself. We use local API helpers that call handlers directly
// to avoid cyclic dependency between loot-core and @actual-app/api
import { send } from './api-helpers';
import * as actual from './api-helpers';
// into Actual itself. We call handlers directly to avoid cyclic dependency
// between loot-core and @actual-app/api
import { app } from '../main-app';
import { runHandler } from '../mutators';
import type {
Budget,
Payee,
@@ -275,10 +275,12 @@ function importAccounts(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.accounts.map(async account => {
if (!account.deleted) {
const id = await actual.createAccount({
name: account.name,
offbudget: account.on_budget ? false : true,
closed: account.closed,
const id = await runHandler(app.handlers['api/account-create'], {
account: {
name: account.name,
offbudget: account.on_budget ? false : true,
closed: account.closed,
},
});
entityIdMap.set(account.id, id);
}
@@ -293,7 +295,9 @@ async function importCategories(
// Hidden categories are put in its own group by YNAB,
// so it's already handled.
const categories = await actual.getCategories();
const categories = await runHandler(app.handlers['api/categories-get'], {
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
const ynabIncomeCategories = ['To be Budgeted', 'Inflow: Ready to Assign'];
@@ -336,7 +340,9 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await actual.createCategoryGroup({ ...params, name });
const id = await runHandler(app.handlers['api/category-group-create'], {
group: { ...params, name },
});
return { id, name };
} catch (e) {
if (count >= MAX_RETRY) {
@@ -359,7 +365,9 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await actual.createCategory({ ...params, name });
const id = await runHandler(app.handlers['api/category-create'], {
category: { ...params, name },
});
return { id, name };
} catch (e) {
if (count >= MAX_RETRY) {
@@ -389,7 +397,7 @@ async function importCategories(
groupId = createdGroup.id;
entityIdMap.set(group.id, groupId);
if (group.note) {
send('notes-save', { id: groupId, note: group.note });
void runHandler(app.handlers['notes-save'], { id: groupId, note: group.note });
}
}
@@ -427,7 +435,7 @@ async function importCategories(
});
entityIdMap.set(cat.id, createdCategory.id);
if (cat.note) {
send('notes-save', {
void runHandler(app.handlers['notes-save'], {
id: createdCategory.id,
note: cat.note,
});
@@ -444,8 +452,8 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.payees.map(async payee => {
if (!payee.deleted) {
const id = await actual.createPayee({
name: payee.name,
const id = await runHandler(app.handlers['api/payee-create'], {
payee: { name: payee.name },
});
entityIdMap.set(payee.id, id);
}
@@ -495,7 +503,7 @@ async function importFlagsAsTags(
await Promise.all(
[...tagsToCreate.entries()].map(async ([tag, color]) => {
await send('tags-create', {
await runHandler(app.handlers['tags-create'], {
tag,
color,
description: 'Imported from YNAB',
@@ -509,8 +517,10 @@ async function importTransactions(
entityIdMap: Map<string, string>,
flagNameConflicts: Set<string>,
) {
const payees = await actual.getPayees();
const categories = await actual.getCategories();
const payees = await runHandler(app.handlers['api/payees-get']);
const categories = await runHandler(app.handlers['api/categories-get'], {
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
const startingBalanceCatId = findIdByName(categories, 'Starting Balances'); //better way to do it?
@@ -773,8 +783,11 @@ async function importTransactions(
})
.filter(x => x);
await actual.addTransactions(entityIdMap.get(accountId), toImport, {
await runHandler(app.handlers['api/transactions-add'], {
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
runTransfers: false,
});
}),
);
@@ -794,7 +807,7 @@ async function importScheduledTransactions(
return;
}
const payees = await actual.getPayees();
const payees = await runHandler(app.handlers['api/payees-get']);
const payeesByTransferAcct = payees
.filter(payee => payee?.transfer_acct)
.map(payee => [payee.transfer_acct, payee] as [string, Payee]);
@@ -817,7 +830,10 @@ async function importScheduledTransactions(
while (true) {
try {
return await actual.createSchedule({ ...params, name: params.name });
return await runHandler(app.handlers['api/schedule-create'], {
...params,
name: params.name,
});
} catch (e) {
if (count >= MAX_RETRY) {
const errorMsg = normalizeError(e);
@@ -832,16 +848,16 @@ async function importScheduledTransactions(
async function getRuleForSchedule(
scheduleId: string,
): Promise<RuleEntity | null> {
const { data: ruleId } = (await actual.aqlQuery(
q('schedules').filter({ id: scheduleId }).calculate('rule'),
)) as { data: string | null };
const { data: ruleId } = (await runHandler(app.handlers['api/query'], {
query: q('schedules').filter({ id: scheduleId }).calculate('rule').serialize(),
})) as { data: string | null };
if (!ruleId) {
return null;
}
const { data: ruleData } = (await actual.aqlQuery(
q('rules').filter({ id: ruleId }).select('*'),
)) as { data: Array<Record<string, unknown>> };
const { data: ruleData } = (await runHandler(app.handlers['api/query'], {
query: q('rules').filter({ id: ruleId }).select('*').serialize(),
})) as { data: Array<Record<string, unknown>> };
const ruleRow = ruleData?.[0];
if (!ruleRow) {
return null;
@@ -900,7 +916,9 @@ async function importScheduledTransactions(
value: scheduleNotes,
});
await actual.updateRule(buildRuleUpdate(rule, actions));
await runHandler(app.handlers['api/rule-update'], {
rule: buildRuleUpdate(rule, actions),
});
}
}
@@ -933,7 +951,9 @@ async function importScheduledTransactions(
value: categoryId,
});
await actual.updateRule(buildRuleUpdate(rule, actions));
await runHandler(app.handlers['api/rule-update'], {
rule: buildRuleUpdate(rule, actions),
});
}
for (const [scheduleId, subtransactions] of scheduleSplitsMap.entries()) {
@@ -1010,7 +1030,9 @@ async function importScheduledTransactions(
}
});
await actual.updateRule(buildRuleUpdate(rule, actions));
await runHandler(app.handlers['api/rule-update'], {
rule: buildRuleUpdate(rule, actions),
});
}
}
}
@@ -1035,7 +1057,8 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
'Credit Card Payments',
);
await actual.batchBudgetUpdates(async () => {
await runHandler(app.handlers['api/batch-budget-start']);
try {
for (const budget of budgets) {
const month = monthUtils.monthFromDate(budget.month);
@@ -1052,11 +1075,17 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
return;
}
await actual.setBudgetAmount(month, catId, amount);
await runHandler(app.handlers['api/budget-set-amount'], {
month,
categoryId: catId,
amount,
});
}),
);
}
});
} finally {
await runHandler(app.handlers['api/batch-budget-end']);
}
}
export function parseFile(buffer: Buffer): Budget {