refactor(api): defineConfig vitest, api-helpers, drop vite.api build

- Wrap api vitest.config with defineConfig for typing/IDE
- Add loot-core api-helpers, use in YNAB4/YNAB5 importers
- Remove vite.api.config, build-api, injected.js; simplify api package
This commit is contained in:
Matiss Janis Aboltins
2026-01-28 22:06:18 +00:00
parent f71249f510
commit ae763af100
17 changed files with 173 additions and 142 deletions

View File

@@ -4,16 +4,16 @@ import type {
} from 'node-fetch';
// loot-core types
import type { InitConfig } from 'loot-core/server/main';
import {
init as initLootCore,
lib,
type InitConfig,
} from 'loot-core/server/main';
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import * as injected from './injected';
import { validateNodeVersion } from './validateNodeVersion';
let actualApp: null | typeof bundle.lib;
export const internal = bundle.lib;
let actualApp: null | typeof lib;
export const internal = lib;
export * from './methods';
export * as utils from './utils';
@@ -33,11 +33,10 @@ export async function init(config: InitConfig = {}) {
};
}
await bundle.init(config);
actualApp = bundle.lib;
await initLootCore(config);
actualApp = lib;
injected.override(bundle.lib.send);
return bundle.lib;
return lib;
}
export async function shutdown() {

View File

@@ -1,7 +0,0 @@
// TODO: comment on why it works this way
export let send;
export function override(sendImplementation) {
send = sendImplementation;
}

View File

@@ -6,7 +6,9 @@ import type {
APIPayeeEntity,
APIScheduleEntity,
} from 'loot-core/server/api-models';
import { lib } from 'loot-core/server/main';
import type { Query } from 'loot-core/shared/query';
import type { ImportTransactionsOpts } from 'loot-core/types/api-handlers';
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
@@ -14,15 +16,13 @@ import type {
TransactionEntity,
} from 'loot-core/types/models';
import * as injected from './injected';
export { q } from './app/query';
function send<K extends keyof Handlers, T extends Handlers[K]>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> {
return injected.send(name, args);
return lib.send(name, args);
}
export async function runImport(
@@ -125,10 +125,7 @@ export function addTransactions(
});
}
export type ImportTransactionsOpts = {
defaultCleared?: boolean;
dryRun?: boolean;
};
export type { ImportTransactionsOpts };
export function importTransactions(
accountId: APIAccountEntity['id'],

View File

@@ -10,25 +10,26 @@
"main": "dist/index.js",
"types": "@types/index.d.ts",
"scripts": {
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
"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"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.5.0",
"compare-versions": "^6.1.1",
"loot-core": "workspace:^",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.0.16"
},
"engines": {

View File

@@ -1,6 +1,4 @@
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import { lib } from 'loot-core/server/main';
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;
export const amountToInteger = lib.amountToInteger;
export const integerToAmount = lib.integerToAmount;

View File

@@ -1,10 +1,29 @@
export default {
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
extensions: [
'.api.js',
'.api.ts',
'.api.tsx',
'.electron.js',
'.electron.ts',
'.electron.tsx',
'.js',
'.ts',
'.tsx',
'.json',
],
},
plugins: [peggyLoader()],
test: {
globals: true,
setupFiles: ['./vitest.setup.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
};
});

View File

@@ -0,0 +1,4 @@
// Ensure CRDT proto file is loaded before tests run
// This ensures the proto namespace is set up on globalThis before
// CRDT exports try to access it
import '@actual-app/crdt/src/proto/sync_pb.js';

View File

@@ -1,21 +0,0 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.." || exit 1
ROOT="$(pwd -P)"
yarn tsc -p tsconfig.api.json --outDir ../api/@types/loot-core/
# Copy existing handwritten .d.ts files, as tsc doesn't move them for us
dest="../../api/@types/loot-core"
cd src
find . -type f -name "*.d.ts" | while read -r f
do
d=$(dirname "${f}")
d="${dest}/${d}"
mkdir -p "${d}"
cp "${f}" "${d}"
done
cd "$ROOT"
yarn vite build --config ./vite.api.config.ts;
./bin/copy-migrations ../api

View File

@@ -54,7 +54,6 @@
"scripts": {
"build:node": "cross-env NODE_ENV=production vite build --config ./vite.desktop.config.ts",
"watch:node": "cross-env NODE_ENV=development vite build --config ./vite.desktop.config.ts --watch",
"build:api": "cross-env NODE_ENV=development ./bin/build-api",
"build:browser": "cross-env NODE_ENV=production ./bin/build-browser",
"watch:browser": "cross-env NODE_ENV=development ./bin/build-browser",
"generate:i18n": "i18next",
@@ -85,7 +84,6 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@actual-app/api": "workspace:^",
"@actual-app/crdt": "workspace:^",
"@actual-app/web": "workspace:^",
"@swc/core": "^1.15.8",

View File

@@ -0,0 +1,100 @@
// @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 Handlers } from '../../types/handlers';
import type { ImportTransactionEntity } from '../../types/models';
import type {
APIAccountEntity,
APICategoryEntity,
APICategoryGroupEntity,
APIPayeeEntity,
} 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 });
}

View File

@@ -1,10 +1,3 @@
// @ts-strict-ignore
// This is a special usage of the API because this package is embedded
// into Actual itself. We only want to pull in the methods in that
// case and ignore everything else; otherwise we'd be pulling in the
// entire backend bundle from the API
import { send } from '@actual-app/api/injected';
import * as actual from '@actual-app/api/methods';
import AdmZip from 'adm-zip';
import normalizePathSep from 'slash';
import { v4 as uuidv4 } from 'uuid';
@@ -13,6 +6,12 @@ import { logger } from '../../platform/server/log';
import * as monthUtils from '../../shared/months';
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';
import type * as YNAB4 from './ynab4-types';
// Importer

View File

@@ -1,16 +1,15 @@
// @ts-strict-ignore
// This is a special usage of the API because this package is embedded
// into Actual itself. We only want to pull in the methods in that
// case and ignore everything else; otherwise we'd be pulling in the
// entire backend bundle from the API
import { send } from '@actual-app/api/injected';
import * as actual from '@actual-app/api/methods';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../platform/server/log';
import * as monthUtils from '../../shared/months';
import { 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';
import type * as YNAB5 from './ynab5-types';
function amountFromYnab(amount: number) {

View File

@@ -1,7 +1,5 @@
// @ts-strict-ignore
import './polyfills';
import * as injectAPI from '@actual-app/api/injected';
import * as asyncStorage from '../platform/server/asyncStorage';
import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
@@ -126,8 +124,6 @@ handlers['app-focused'] = async function () {
handlers = installAPI(handlers) as Handlers;
injectAPI.override((name, args) => runHandler(app.handlers[name], args));
// A hack for now until we clean up everything
app.handlers = handlers;
app.combine(

View File

@@ -1,6 +1,4 @@
// @ts-strict-ignore
import { type ImportTransactionsOpts } from '@actual-app/api';
import type { ImportTransactionsResult } from '../server/accounts/app';
import type {
APIAccountEntity,
@@ -22,6 +20,11 @@ import type {
TransactionEntity,
} from './models';
export type ImportTransactionsOpts = {
defaultCleared?: boolean;
dryRun?: boolean;
};
export type ApiHandlers = {
'api/batch-budget-start': () => Promise<void>;

View File

@@ -1,61 +0,0 @@
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import peggyLoader from 'vite-plugin-peggy-loader';
export default defineConfig(({ mode }) => {
const outDir = path.resolve(__dirname, '../api/app');
const crdtDir = path.resolve(__dirname, '../crdt');
return {
mode,
ssr: { noExternal: true, external: ['better-sqlite3'] },
build: {
target: 'node18',
outDir,
emptyOutDir: false,
ssr: true,
lib: {
entry: path.resolve(__dirname, 'src/server/main.ts'),
formats: ['cjs'],
},
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'bundle.api.js',
format: 'cjs',
name: 'api',
},
},
},
resolve: {
extensions: [
'.api.js',
'.api.ts',
'.api.tsx',
'.electron.js',
'.electron.ts',
'.electron.tsx',
'.js',
'.ts',
'.tsx',
'.json',
],
alias: [
{
find: 'handlebars',
replacement: require.resolve('handlebars/dist/handlebars.js'),
},
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(crdtDir, 'src') + '$1',
},
],
},
plugins: [
peggyLoader(),
visualizer({ template: 'raw-data', filename: `${outDir}/stats.json` }),
],
};
});

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Remove cyclic dependency between API and loot-core

View File

@@ -19,17 +19,19 @@ __metadata:
languageName: node
linkType: hard
"@actual-app/api@workspace:^, @actual-app/api@workspace:packages/api":
"@actual-app/api@workspace:packages/api":
version: 0.0.0-use.local
resolution: "@actual-app/api@workspace:packages/api"
dependencies:
"@actual-app/crdt": "workspace:^"
better-sqlite3: "npm:^12.5.0"
compare-versions: "npm:^6.1.1"
loot-core: "workspace:^"
node-fetch: "npm:^3.3.2"
tsc-alias: "npm:^1.8.16"
typescript: "npm:^5.9.3"
uuid: "npm:^13.0.0"
vite-plugin-peggy-loader: "npm:^2.0.1"
vitest: "npm:^4.0.16"
languageName: unknown
linkType: soft
@@ -19213,11 +19215,10 @@ __metadata:
languageName: node
linkType: hard
"loot-core@workspace:*, loot-core@workspace:packages/loot-core":
"loot-core@workspace:*, loot-core@workspace:^, loot-core@workspace:packages/loot-core":
version: 0.0.0-use.local
resolution: "loot-core@workspace:packages/loot-core"
dependencies:
"@actual-app/api": "workspace:^"
"@actual-app/crdt": "workspace:^"
"@actual-app/web": "workspace:^"
"@jlongster/sql.js": "npm:^1.6.7"