diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
new file mode 100644
index 0000000000..38275dbea4
--- /dev/null
+++ b/.github/workflows/benchmark.yml
@@ -0,0 +1,40 @@
+name: Benchmarks
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ paths:
+ - 'packages/loot-core/**'
+ - 'packages/crdt/**'
+ - 'vitest.bench.config.mts'
+ - 'package.json'
+ - 'yarn.lock'
+ - '.github/workflows/benchmark.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
+
+jobs:
+ benchmark:
+ runs-on: depot-ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Set up environment
+ uses: ./.github/actions/setup
+ with:
+ download-translations: 'false'
+ - name: Run benchmarks
+ uses: CodSpeedHQ/action@1aa3d227c0eaa4157abb72f64750a032cac12a9b # v4.8.2
+ with:
+ run: yarn bench
+ token: ${{ secrets.CODSPEED_TOKEN }}
diff --git a/package.json b/package.json
index 5e24e97cba..752796d434 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "lage test --continue",
"test:debug": "lage test --no-cache --continue",
+ "bench": "vitest bench --run --config vitest.bench.config.mts",
"e2e": "yarn workspace @actual-app/web run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build --skip-translations && yarn workspace desktop-electron e2e",
"playwright": "yarn workspace @actual-app/web run playwright",
@@ -64,6 +65,7 @@
"prepare": "husky"
},
"devDependencies": {
+ "@codspeed/vitest-plugin": "^5.6.0",
"@monorepo-utils/workspaces-to-typescript-project-references": "^2.11.0",
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.21",
diff --git a/packages/crdt/src/crdt/merkle.bench.ts b/packages/crdt/src/crdt/merkle.bench.ts
new file mode 100644
index 0000000000..fa9e1aca41
--- /dev/null
+++ b/packages/crdt/src/crdt/merkle.bench.ts
@@ -0,0 +1,46 @@
+import { bench, describe } from 'vitest';
+
+import * as merkle from './merkle';
+import { Timestamp } from './timestamp';
+
+const NODE = '0123456789abcdef';
+const MINUTE = 1000 * 60;
+const BASE = Date.UTC(2020, 0, 1);
+
+function makeTimestamps(count: number, startMillis = BASE): Timestamp[] {
+ const timestamps: Timestamp[] = [];
+ for (let i = 0; i < count; i++) {
+ // Spread messages across time so the trie gains realistic depth.
+ timestamps.push(new Timestamp(startMillis + i * MINUTE, i % 100, NODE));
+ }
+ return timestamps;
+}
+
+const small = makeTimestamps(100);
+const large = makeTimestamps(5000);
+const largeTrie = merkle.build(large);
+
+// A second trie that diverges from `largeTrie` near the end, simulating two
+// clients that have synced most of their history but differ on recent edits.
+const diverged = merkle.build([
+ ...large.slice(0, large.length - 10),
+ ...makeTimestamps(10, BASE + large.length * MINUTE * 2),
+]);
+
+describe('merkle trie', () => {
+ bench('build trie from 100 messages', () => {
+ merkle.build(small);
+ });
+
+ bench('build trie from 5000 messages', () => {
+ merkle.build(large);
+ });
+
+ bench('diff two large tries', () => {
+ merkle.diff(largeTrie, diverged);
+ });
+
+ bench('prune large trie', () => {
+ merkle.prune(largeTrie);
+ });
+});
diff --git a/packages/loot-core/src/server/aql/compiler.bench.ts b/packages/loot-core/src/server/aql/compiler.bench.ts
new file mode 100644
index 0000000000..417244574e
--- /dev/null
+++ b/packages/loot-core/src/server/aql/compiler.bench.ts
@@ -0,0 +1,87 @@
+import { bench, describe } from 'vitest';
+
+import { q } from '#shared/query';
+
+import { generateSQLWithState } from './compiler';
+
+const schema = {
+ transactions: {
+ id: { type: 'id' },
+ date: { type: 'date' },
+ amount: { type: 'integer' },
+ notes: { type: 'string' },
+ cleared: { type: 'boolean' },
+ payee: { type: 'id', ref: 'payees' },
+ category: { type: 'id', ref: 'categories' },
+ account: { type: 'id', ref: 'accounts' },
+ tombstone: { type: 'boolean' },
+ },
+ payees: {
+ id: { type: 'id' },
+ name: { type: 'string' },
+ tombstone: { type: 'boolean' },
+ },
+ categories: {
+ id: { type: 'id' },
+ name: { type: 'string' },
+ is_income: { type: 'boolean' },
+ tombstone: { type: 'boolean' },
+ },
+ accounts: {
+ id: { type: 'id' },
+ name: { type: 'string' },
+ offbudget: { type: 'boolean' },
+ tombstone: { type: 'boolean' },
+ },
+};
+
+const simpleQuery = q('transactions')
+ .filter({ account: 'abc' })
+ .select(['date', 'amount', 'notes'])
+ .serialize();
+
+const complexQuery = q('transactions')
+ .filter({
+ $and: [
+ { 'account.offbudget': false },
+ { date: { $gte: '2023-01-01' } },
+ { date: { $lte: '2023-12-31' } },
+ {
+ $or: [
+ { 'category.is_income': true },
+ { amount: { $lt: 0 } },
+ { 'payee.name': { $like: '%market%' } },
+ ],
+ },
+ ],
+ })
+ .select([
+ 'date',
+ 'amount',
+ 'notes',
+ 'payee.name',
+ 'category.name',
+ 'account.name',
+ ])
+ .orderBy([{ date: 'desc' }, 'amount'])
+ .serialize();
+
+const groupedQuery = q('transactions')
+ .filter({ 'account.offbudget': false })
+ .groupBy(['category.name'])
+ .select(['category.name', { total: { $sum: '$amount' } }])
+ .serialize();
+
+describe('aql compiler', () => {
+ bench('compile simple query', () => {
+ generateSQLWithState(simpleQuery, schema);
+ });
+
+ bench('compile complex filtered/joined query', () => {
+ generateSQLWithState(complexQuery, schema);
+ });
+
+ bench('compile grouped aggregate query', () => {
+ generateSQLWithState(groupedQuery, schema);
+ });
+});
diff --git a/packages/loot-core/src/server/transactions/import/ofx2json.bench.ts b/packages/loot-core/src/server/transactions/import/ofx2json.bench.ts
new file mode 100644
index 0000000000..2c5abb1af4
--- /dev/null
+++ b/packages/loot-core/src/server/transactions/import/ofx2json.bench.ts
@@ -0,0 +1,62 @@
+import { bench, describe } from 'vitest';
+
+import { ofx2json } from './ofx2json';
+
+function generateOfx(transactionCount: number): string {
+ const header = [
+ 'OFXHEADER:100',
+ 'DATA:OFXSGML',
+ 'VERSION:102',
+ 'SECURITY:NONE',
+ 'ENCODING:USASCII',
+ 'CHARSET:1252',
+ 'COMPRESSION:NONE',
+ 'OLDFILEUID:NONE',
+ 'NEWFILEUID:NONE',
+ '',
+ ].join('\n');
+
+ const transactions: string[] = [];
+ for (let i = 0; i < transactionCount; i++) {
+ const day = String((i % 28) + 1).padStart(2, '0');
+ transactions.push(
+ '',
+ 'DEBIT',
+ `201801${day}120000`,
+ `-${(i % 1000) + 0.99}`,
+ `${i}`,
+ `Payee Name ${i % 50}`,
+ `Memo for transaction ${i}`,
+ '',
+ );
+ }
+
+ return `${header}
+
+
+
+
+USD
+
+20180101120000
+20181231120000
+${transactions.join('\n')}
+
+
+
+
+`;
+}
+
+const small = generateOfx(100);
+const large = generateOfx(2000);
+
+describe('ofx2json', () => {
+ bench('parse 100 transactions', async () => {
+ await ofx2json(small);
+ });
+
+ bench('parse 2000 transactions', async () => {
+ await ofx2json(large);
+ });
+});
diff --git a/packages/loot-core/src/server/transactions/import/qif2json.bench.ts b/packages/loot-core/src/server/transactions/import/qif2json.bench.ts
new file mode 100644
index 0000000000..4a2c560328
--- /dev/null
+++ b/packages/loot-core/src/server/transactions/import/qif2json.bench.ts
@@ -0,0 +1,34 @@
+import { bench, describe } from 'vitest';
+
+import { qif2json } from './qif2json';
+
+function generateQif(transactionCount: number): string {
+ const lines = ['!Type:Bank'];
+ for (let i = 0; i < transactionCount; i++) {
+ const day = (i % 28) + 1;
+ const month = (i % 12) + 1;
+ lines.push(
+ `D${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}/18`,
+ `T-${(i % 1000) + 0.99}`,
+ `N${i}`,
+ `PPayee Name ${i % 50}`,
+ `MMemo for transaction ${i}`,
+ `LCategory:Subcategory${i % 20}`,
+ '^',
+ );
+ }
+ return lines.join('\n');
+}
+
+const small = generateQif(100);
+const large = generateQif(5000);
+
+describe('qif2json', () => {
+ bench('parse 100 transactions', () => {
+ qif2json(small, { dateFormat: 'MM/dd/yy' });
+ });
+
+ bench('parse 5000 transactions', () => {
+ qif2json(large, { dateFormat: 'MM/dd/yy' });
+ });
+});
diff --git a/packages/loot-core/src/server/transactions/import/xmlcamt2json.bench.ts b/packages/loot-core/src/server/transactions/import/xmlcamt2json.bench.ts
new file mode 100644
index 0000000000..1265a584e9
--- /dev/null
+++ b/packages/loot-core/src/server/transactions/import/xmlcamt2json.bench.ts
@@ -0,0 +1,80 @@
+import { bench, describe } from 'vitest';
+
+import { xmlCAMT2json } from './xmlcamt2json';
+
+function generateEntry(i: number): string {
+ const day = String((i % 28) + 1).padStart(2, '0');
+ const isDebit = i % 2 === 0;
+ return `
+
+ ${(i % 1000) + 0.6}
+ ${isDebit ? 'DBIT' : 'CRDT'}
+ BOOK
+
+ 2014-01-${day}
+
+
+ 2014-01-${day}
+
+ 2013123001153870${String(i).padStart(3, '0')}
+
+
+
+ STZV-EtE-${i}
+
+
+
+ Debtor ${i % 50}
+
+
+ Creditor ${i % 50}
+
+
+
+ Payment reference for transaction ${i}
+
+
+
+ `;
+}
+
+function generateCamt(transactionCount: number): string {
+ const entries: string[] = [];
+ for (let i = 0; i < transactionCount; i++) {
+ entries.push(generateEntry(i));
+ }
+ return `
+
+
+
+ 053D2014
+ 2014-01-03T22:01:36.0+01:00
+
+
+ 0352C5320140103220142
+
+
+ DE14740618130000033626
+
+ EUR
+
+ ${entries.join('')}
+
+
+`;
+}
+
+const small = generateCamt(100);
+const large = generateCamt(2000);
+
+describe('xmlCAMT2json', () => {
+ bench('parse 100 transactions', async () => {
+ await xmlCAMT2json(small);
+ });
+
+ bench('parse 2000 transactions', async () => {
+ await xmlCAMT2json(large);
+ });
+});
diff --git a/upcoming-release-notes/benchmark-codspeed.md b/upcoming-release-notes/benchmark-codspeed.md
new file mode 100644
index 0000000000..93b53b6b73
--- /dev/null
+++ b/upcoming-release-notes/benchmark-codspeed.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+Add continuous benchmarking with vitest bench and CodSpeed to guard runtime performance of hot paths (CRDT merge, AQL query compilation, and OFX/QIF/CAMT import parsers)
diff --git a/vitest.bench.config.mts b/vitest.bench.config.mts
new file mode 100644
index 0000000000..ee4f9c1c9d
--- /dev/null
+++ b/vitest.bench.config.mts
@@ -0,0 +1,23 @@
+import codspeedPlugin from '@codspeed/vitest-plugin';
+import { defineConfig } from 'vitest/config';
+
+// Continuous benchmarking config. Benchmarks live next to the code they measure
+// as `*.bench.ts` files and run with `yarn bench`. In CI the CodSpeed plugin
+// instruments them so performance deltas can be reported on pull requests.
+export default defineConfig({
+ plugins: [codspeedPlugin()],
+ // Mirror loot-core's node test resolution so platform-specific (`#platform`,
+ // `#shared`, …) subpath imports resolve the same way they do under `yarn test`.
+ resolve: {
+ conditions: ['electron', 'module', 'browser', 'development'],
+ },
+ ssr: {
+ resolve: { conditions: ['electron', 'module', 'node', 'development'] },
+ },
+ test: {
+ benchmark: {
+ include: ['packages/*/src/**/*.bench.ts'],
+ exclude: ['**/node_modules/**', '**/dist/**', '**/lib-dist/**'],
+ },
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index 1c218c1a8d..722e1f6e09 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2019,6 +2019,32 @@ __metadata:
languageName: node
linkType: hard
+"@codspeed/core@npm:^5.6.0":
+ version: 5.6.0
+ resolution: "@codspeed/core@npm:5.6.0"
+ dependencies:
+ axios: "npm:^1.4.0"
+ find-up: "npm:^6.3.0"
+ form-data: "npm:^4.0.4"
+ node-gyp-build: "npm:^4.6.0"
+ stack-trace: "npm:1.0.0-pre2"
+ checksum: 10/36ef33a56a0f6dccf9ec19e420ed02ffdbd22d9e86c467a66d72f48b9eb75217ca0c91763cec13c0b27916bc843e9347a2afae800da556638d1ef240d4aee880
+ languageName: node
+ linkType: hard
+
+"@codspeed/vitest-plugin@npm:^5.6.0":
+ version: 5.6.0
+ resolution: "@codspeed/vitest-plugin@npm:5.6.0"
+ dependencies:
+ "@codspeed/core": "npm:^5.6.0"
+ peerDependencies:
+ tinybench: ">=2.9.0"
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ vitest: ^3.2 || ^4
+ checksum: 10/476cbea5f230fae5126f1b0f97fe844e43480781b709a77a10ca6d6e9e117ce6ca277da1b5a17c26b6c2cbcfceb1690f9bdca2e129e4fddec9f4911fe6c9ff95
+ languageName: node
+ linkType: hard
+
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
@@ -9409,6 +9435,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "actual@workspace:."
dependencies:
+ "@codspeed/vitest-plugin": "npm:^5.6.0"
"@monorepo-utils/workspaces-to-typescript-project-references": "npm:^2.11.0"
"@octokit/rest": "npm:^22.0.1"
"@types/node": "npm:^22.19.21"
@@ -10010,6 +10037,18 @@ __metadata:
languageName: node
linkType: hard
+"axios@npm:^1.4.0":
+ version: 1.18.0
+ resolution: "axios@npm:1.18.0"
+ dependencies:
+ follow-redirects: "npm:^1.16.0"
+ form-data: "npm:^4.0.5"
+ https-proxy-agent: "npm:^5.0.1"
+ proxy-from-env: "npm:^2.1.0"
+ checksum: 10/586a1a9534531c6ec4ee8fbab07a536ecaa8d29bd16b40ab0f9fce361fcd24da1538a54aadde0562f08f30b55bf80f9b7ededf1987e6506f722e1fb338b09d5d
+ languageName: node
+ linkType: hard
+
"b4a@npm:^1.6.4":
version: 1.7.3
resolution: "b4a@npm:1.7.3"
@@ -14950,7 +14989,7 @@ __metadata:
languageName: node
linkType: hard
-"form-data@npm:^4.0.0, form-data@npm:^4.0.5":
+"form-data@npm:^4.0.0, form-data@npm:^4.0.4, form-data@npm:^4.0.5":
version: 4.0.6
resolution: "form-data@npm:4.0.6"
dependencies:
@@ -19784,7 +19823,7 @@ __metadata:
languageName: node
linkType: hard
-"node-gyp-build@npm:^4.8.4":
+"node-gyp-build@npm:^4.6.0, node-gyp-build@npm:^4.8.4":
version: 4.8.4
resolution: "node-gyp-build@npm:4.8.4"
bin:
@@ -24870,6 +24909,13 @@ __metadata:
languageName: node
linkType: hard
+"stack-trace@npm:1.0.0-pre2":
+ version: 1.0.0-pre2
+ resolution: "stack-trace@npm:1.0.0-pre2"
+ checksum: 10/a64099f86acc01980b0a7fbc662f3233bf8626daf95c53e31c835b2252ae11fc3dbfe8f3e77a7f8310132dd488af2795057cd7db599de0c41a6fa99b16068273
+ languageName: node
+ linkType: hard
+
"stackback@npm:0.0.2":
version: 0.0.2
resolution: "stackback@npm:0.0.2"