mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
2 Commits
js-proxy
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689b6f761f | ||
|
|
7318015a1f |
76
.github/workflows/size-compare.yml
vendored
76
.github/workflows/size-compare.yml
vendored
@@ -50,6 +50,8 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.base_ref}}
|
||||
timeoutSeconds: 1200
|
||||
intervalSeconds: 30
|
||||
- name: Wait for ${{github.base_ref}} API build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-api-build
|
||||
@@ -57,6 +59,8 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.base_ref}}
|
||||
timeoutSeconds: 1200
|
||||
intervalSeconds: 30
|
||||
- name: Wait for ${{github.base_ref}} CLI build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-cli-build
|
||||
@@ -64,6 +68,8 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.base_ref}}
|
||||
timeoutSeconds: 1200
|
||||
intervalSeconds: 30
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
@@ -72,6 +78,8 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
timeoutSeconds: 1200
|
||||
intervalSeconds: 30
|
||||
- name: Wait for API PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-api-build
|
||||
@@ -79,6 +87,8 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
timeoutSeconds: 1200
|
||||
intervalSeconds: 30
|
||||
- name: Wait for CLI PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-cli-build
|
||||
@@ -86,12 +96,32 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
timeoutSeconds: 1200
|
||||
intervalSeconds: 30
|
||||
|
||||
- name: Report build failure
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
|
||||
if: |
|
||||
steps.wait-for-web-build.outputs.conclusion == 'failure' ||
|
||||
steps.wait-for-api-build.outputs.conclusion == 'failure' ||
|
||||
steps.wait-for-cli-build.outputs.conclusion == 'failure' ||
|
||||
steps.master-web-build.outputs.conclusion == 'failure' ||
|
||||
steps.master-api-build.outputs.conclusion == 'failure' ||
|
||||
steps.master-cli-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${{github.base_ref}}"
|
||||
exit 1
|
||||
- name: Warn on incomplete builds
|
||||
if: |
|
||||
steps.wait-for-web-build.outputs.conclusion != 'success' ||
|
||||
steps.wait-for-api-build.outputs.conclusion != 'success' ||
|
||||
steps.wait-for-cli-build.outputs.conclusion != 'success' ||
|
||||
steps.master-web-build.outputs.conclusion != 'success' ||
|
||||
steps.master-api-build.outputs.conclusion != 'success' ||
|
||||
steps.master-cli-build.outputs.conclusion != 'success'
|
||||
run: |
|
||||
echo "::warning::Some builds did not complete successfully. Bundle stats may be incomplete."
|
||||
echo "Base branch - web: ${{ steps.master-web-build.outputs.conclusion }}, api: ${{ steps.master-api-build.outputs.conclusion }}, cli: ${{ steps.master-cli-build.outputs.conclusion }}"
|
||||
echo "PR - web: ${{ steps.wait-for-web-build.outputs.conclusion }}, api: ${{ steps.wait-for-api-build.outputs.conclusion }}, cli: ${{ steps.wait-for-cli-build.outputs.conclusion }}"
|
||||
|
||||
- name: Download web build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
@@ -102,6 +132,7 @@ jobs:
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: base
|
||||
if_no_artifact_found: warn
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
id: pr-api-build
|
||||
@@ -111,41 +142,46 @@ jobs:
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: base
|
||||
if_no_artifact_found: warn
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
commit: ${{github.event.pull_request.head.sha}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
if_no_artifact_found: warn
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
commit: ${{github.event.pull_request.head.sha}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
if_no_artifact_found: warn
|
||||
- name: Download CLI build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: base
|
||||
if_no_artifact_found: warn
|
||||
- name: Download CLI stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
commit: ${{github.event.pull_request.head.sha}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
if_no_artifact_found: warn
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
if [ -f ./head/web-stats.json ]; then
|
||||
@@ -162,19 +198,31 @@ jobs:
|
||||
fi
|
||||
done
|
||||
- name: Generate combined bundle stats comment
|
||||
if: ${{ !cancelled() }}
|
||||
id: generate-comment
|
||||
run: |
|
||||
ARGS=""
|
||||
for bundle in "desktop-client=web-stats.json" "loot-core=loot-core-stats.json" "api=api-stats.json" "cli=cli-stats.json"; do
|
||||
NAME="${bundle%%=*}"
|
||||
FILE="${bundle#*=}"
|
||||
if [ -f "./base/$FILE" ] && [ -f "./head/$FILE" ]; then
|
||||
ARGS="$ARGS --base $NAME=./base/$FILE --head $NAME=./head/$FILE"
|
||||
else
|
||||
echo "::warning::Skipping $NAME: base or head stats file missing"
|
||||
fi
|
||||
done
|
||||
if [ -z "$ARGS" ]; then
|
||||
echo "::warning::No stats files available, skipping comment generation"
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
node packages/ci-actions/bin/bundle-stats-comment.mjs \
|
||||
--base desktop-client=./base/web-stats.json \
|
||||
--base loot-core=./base/loot-core-stats.json \
|
||||
--base api=./base/api-stats.json \
|
||||
--base cli=./base/cli-stats.json \
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--head cli=./head/cli-stats.json \
|
||||
$ARGS \
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
echo "has_comment=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Post combined bundle stats comment
|
||||
if: ${{ !cancelled() && steps.generate-comment.outputs.has_comment == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/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
|
||||
@@ -5,7 +5,7 @@
|
||||
* Heavily inspired by https://github.com/twk3/rollup-size-compare-action (MIT).
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
@@ -179,8 +179,19 @@ function parseArgs(argv) {
|
||||
}
|
||||
|
||||
async function loadStats(filePath) {
|
||||
const absolutePath = path.resolve(process.cwd(), filePath);
|
||||
|
||||
// Check if the file exists before trying to read it
|
||||
try {
|
||||
await access(absolutePath);
|
||||
} catch {
|
||||
console.error(
|
||||
`[bundle-stats] Stats file not found: "${filePath}" — skipping`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const absolutePath = path.resolve(process.cwd(), filePath);
|
||||
const fileContents = await readFile(absolutePath, 'utf8');
|
||||
const parsed = JSON.parse(fileContents);
|
||||
|
||||
@@ -196,7 +207,7 @@ async function loadStats(filePath) {
|
||||
? error.message
|
||||
: 'Unknown error while parsing stats file';
|
||||
console.error(`[bundle-stats] Failed to parse "${filePath}": ${message}`);
|
||||
throw new Error(`Failed to load stats file "${filePath}": ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,6 +698,13 @@ async function main() {
|
||||
);
|
||||
const headStats = await loadStats(section.headPath);
|
||||
|
||||
if (!baseStats || !headStats) {
|
||||
console.error(
|
||||
`[bundle-stats] Skipping section "${section.name}": missing ${!baseStats ? 'base' : 'head'} stats`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const statsDiff = getStatsDiff(baseStats, headStats);
|
||||
const chunkDiff = getChunkModuleDiff(baseStats, headStats);
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import { parseOrderBy, registerQueryCommand } from './query';
|
||||
|
||||
vi.mock('@actual-app/api', () => {
|
||||
const queryObj = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
filter: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnThis(),
|
||||
groupBy: vi.fn().mockReturnThis(),
|
||||
calculate: vi.fn().mockReturnThis(),
|
||||
};
|
||||
return {
|
||||
q: vi.fn().mockReturnValue(queryObj),
|
||||
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../connection', () => ({
|
||||
withConnection: vi.fn((_opts, fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('../output', () => ({
|
||||
printOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.option('--format <format>');
|
||||
program.option('--server-url <url>');
|
||||
program.option('--password <pw>');
|
||||
program.option('--session-token <token>');
|
||||
program.option('--sync-id <id>');
|
||||
program.option('--data-dir <dir>');
|
||||
program.option('--verbose');
|
||||
program.exitOverride();
|
||||
registerQueryCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
}
|
||||
|
||||
function getQueryObj() {
|
||||
return vi.mocked(api.q).mock.results[0]?.value;
|
||||
}
|
||||
|
||||
describe('parseOrderBy', () => {
|
||||
it('parses plain field names', () => {
|
||||
expect(parseOrderBy('date')).toEqual(['date']);
|
||||
});
|
||||
|
||||
it('parses field:desc', () => {
|
||||
expect(parseOrderBy('date:desc')).toEqual([{ date: 'desc' }]);
|
||||
});
|
||||
|
||||
it('parses field:asc', () => {
|
||||
expect(parseOrderBy('amount:asc')).toEqual([{ amount: 'asc' }]);
|
||||
});
|
||||
|
||||
it('parses multiple mixed fields', () => {
|
||||
expect(parseOrderBy('date:desc,amount:asc,id')).toEqual([
|
||||
{ date: 'desc' },
|
||||
{ amount: 'asc' },
|
||||
'id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on invalid direction', () => {
|
||||
expect(() => parseOrderBy('date:backwards')).toThrow(
|
||||
'Invalid order direction "backwards"',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on empty field', () => {
|
||||
expect(() => parseOrderBy('date,,amount')).toThrow('empty field');
|
||||
});
|
||||
});
|
||||
|
||||
describe('query commands', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('builds a basic query from flags', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--select',
|
||||
'date,amount',
|
||||
'--limit',
|
||||
'5',
|
||||
]);
|
||||
|
||||
expect(api.q).toHaveBeenCalledWith('transactions');
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
|
||||
expect(qObj.limit).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('rejects unknown table name', async () => {
|
||||
await expect(
|
||||
run(['query', 'run', '--table', 'nonexistent']),
|
||||
).rejects.toThrow('Unknown table "nonexistent"');
|
||||
});
|
||||
|
||||
it('parses order-by with desc direction', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--order-by',
|
||||
'date:desc,amount:asc',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.orderBy).toHaveBeenCalledWith([
|
||||
{ date: 'desc' },
|
||||
{ amount: 'asc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes --filter as JSON', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--filter',
|
||||
'{"amount":{"$lt":0}}',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $lt: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('--last flag', () => {
|
||||
it('sets default table, select, orderBy, and limit', async () => {
|
||||
await run(['query', 'run', '--last', '10']);
|
||||
|
||||
expect(api.q).toHaveBeenCalledWith('transactions');
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.select).toHaveBeenCalledWith([
|
||||
'date',
|
||||
'account.name',
|
||||
'payee.name',
|
||||
'category.name',
|
||||
'amount',
|
||||
'notes',
|
||||
]);
|
||||
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
|
||||
expect(qObj.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('allows explicit --select override', async () => {
|
||||
await run(['query', 'run', '--last', '5', '--select', 'date,amount']);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
|
||||
});
|
||||
|
||||
it('allows explicit --order-by override', async () => {
|
||||
await run(['query', 'run', '--last', '5', '--order-by', 'amount:asc']);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.orderBy).toHaveBeenCalledWith([{ amount: 'asc' }]);
|
||||
});
|
||||
|
||||
it('allows --table transactions explicitly', async () => {
|
||||
await run(['query', 'run', '--last', '5', '--table', 'transactions']);
|
||||
|
||||
expect(api.q).toHaveBeenCalledWith('transactions');
|
||||
});
|
||||
|
||||
it('errors if --table is not transactions', async () => {
|
||||
await expect(
|
||||
run(['query', 'run', '--last', '5', '--table', 'accounts']),
|
||||
).rejects.toThrow('--last implies --table transactions');
|
||||
});
|
||||
|
||||
it('errors if --limit is also set', async () => {
|
||||
await expect(
|
||||
run(['query', 'run', '--last', '5', '--limit', '10']),
|
||||
).rejects.toThrow('--last and --limit are mutually exclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--count flag', () => {
|
||||
it('uses calculate with $count', async () => {
|
||||
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: 42 });
|
||||
|
||||
await run(['query', 'run', '--table', 'transactions', '--count']);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.calculate).toHaveBeenCalledWith({ $count: '*' });
|
||||
expect(printOutput).toHaveBeenCalledWith({ count: 42 }, undefined);
|
||||
});
|
||||
|
||||
it('errors if --select is also set', async () => {
|
||||
await expect(
|
||||
run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--count',
|
||||
'--select',
|
||||
'date',
|
||||
]),
|
||||
).rejects.toThrow('--count and --select are mutually exclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--where alias', () => {
|
||||
it('works the same as --filter', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--where',
|
||||
'{"amount":{"$gt":0}}',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $gt: 0 } });
|
||||
});
|
||||
|
||||
it('errors if both --where and --filter are provided', async () => {
|
||||
await expect(
|
||||
run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--where',
|
||||
'{}',
|
||||
'--filter',
|
||||
'{}',
|
||||
]),
|
||||
).rejects.toThrow('--where and --filter are mutually exclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--offset flag', () => {
|
||||
it('passes offset through to query', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--offset',
|
||||
'20',
|
||||
'--limit',
|
||||
'10',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.offset).toHaveBeenCalledWith(20);
|
||||
expect(qObj.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('--group-by flag', () => {
|
||||
it('passes group-by through to query', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--group-by',
|
||||
'category.name',
|
||||
'--select',
|
||||
'category.name,amount',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.groupBy).toHaveBeenCalledWith(['category.name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tables subcommand', () => {
|
||||
it('lists available tables', async () => {
|
||||
await run(['query', 'tables']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
{ name: 'transactions' },
|
||||
{ name: 'accounts' },
|
||||
{ name: 'categories' },
|
||||
{ name: 'payees' },
|
||||
]),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fields subcommand', () => {
|
||||
it('lists fields for a known table', async () => {
|
||||
await run(['query', 'fields', 'accounts']);
|
||||
|
||||
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
expect(output).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'id', type: 'id' }),
|
||||
expect.objectContaining({ name: 'name', type: 'string' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('errors on unknown table', async () => {
|
||||
await expect(run(['query', 'fields', 'unknown'])).rejects.toThrow(
|
||||
'Unknown table "unknown"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,115 +10,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse order-by strings like "date:desc,amount:asc,id" into
|
||||
* AQL orderBy format: [{ date: 'desc' }, { amount: 'asc' }, 'id']
|
||||
*/
|
||||
export function parseOrderBy(
|
||||
input: string,
|
||||
): Array<string | Record<string, string>> {
|
||||
return input.split(',').map(part => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('--order-by contains an empty field');
|
||||
}
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
return trimmed;
|
||||
}
|
||||
const field = trimmed.slice(0, colonIndex).trim();
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`Invalid order field in "${trimmed}". Field name cannot be empty.`,
|
||||
);
|
||||
}
|
||||
const direction = trimmed.slice(colonIndex + 1);
|
||||
if (direction !== 'asc' && direction !== 'desc') {
|
||||
throw new Error(
|
||||
`Invalid order direction "${direction}" for field "${field}". Expected "asc" or "desc".`,
|
||||
);
|
||||
}
|
||||
return { [field]: direction };
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Import schema from API once it exposes table/field metadata
|
||||
const TABLE_SCHEMA: Record<
|
||||
string,
|
||||
Record<string, { type: string; ref?: string }>
|
||||
> = {
|
||||
transactions: {
|
||||
id: { type: 'id' },
|
||||
account: { type: 'id', ref: 'accounts' },
|
||||
date: { type: 'date' },
|
||||
amount: { type: 'integer' },
|
||||
payee: { type: 'id', ref: 'payees' },
|
||||
category: { type: 'id', ref: 'categories' },
|
||||
notes: { type: 'string' },
|
||||
imported_id: { type: 'string' },
|
||||
transfer_id: { type: 'id' },
|
||||
cleared: { type: 'boolean' },
|
||||
reconciled: { type: 'boolean' },
|
||||
starting_balance_flag: { type: 'boolean' },
|
||||
imported_payee: { type: 'string' },
|
||||
is_parent: { type: 'boolean' },
|
||||
is_child: { type: 'boolean' },
|
||||
parent_id: { type: 'id' },
|
||||
sort_order: { type: 'float' },
|
||||
schedule: { type: 'id', ref: 'schedules' },
|
||||
'account.name': { type: 'string', ref: 'accounts' },
|
||||
'payee.name': { type: 'string', ref: 'payees' },
|
||||
'category.name': { type: 'string', ref: 'categories' },
|
||||
'category.group.name': { type: 'string', ref: 'category_groups' },
|
||||
},
|
||||
accounts: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
offbudget: { type: 'boolean' },
|
||||
closed: { type: 'boolean' },
|
||||
sort_order: { type: 'float' },
|
||||
},
|
||||
categories: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
is_income: { type: 'boolean' },
|
||||
group_id: { type: 'id', ref: 'category_groups' },
|
||||
sort_order: { type: 'float' },
|
||||
hidden: { type: 'boolean' },
|
||||
'group.name': { type: 'string', ref: 'category_groups' },
|
||||
},
|
||||
payees: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
transfer_acct: { type: 'id', ref: 'accounts' },
|
||||
},
|
||||
rules: {
|
||||
id: { type: 'id' },
|
||||
stage: { type: 'string' },
|
||||
conditions_op: { type: 'string' },
|
||||
conditions: { type: 'json' },
|
||||
actions: { type: 'json' },
|
||||
},
|
||||
schedules: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
rule: { type: 'id', ref: 'rules' },
|
||||
next_date: { type: 'date' },
|
||||
completed: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
|
||||
|
||||
const LAST_DEFAULT_SELECT = [
|
||||
'date',
|
||||
'account.name',
|
||||
'payee.name',
|
||||
'category.name',
|
||||
'amount',
|
||||
'notes',
|
||||
];
|
||||
|
||||
function buildQueryFromFile(
|
||||
parsed: Record<string, unknown>,
|
||||
fallbackTable: string | undefined,
|
||||
@@ -136,125 +27,34 @@ function buildQueryFromFile(
|
||||
queryObj = queryObj.orderBy(parsed.orderBy);
|
||||
}
|
||||
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
|
||||
if (typeof parsed.offset === 'number') {
|
||||
queryObj = queryObj.offset(parsed.offset);
|
||||
}
|
||||
if (Array.isArray(parsed.groupBy)) {
|
||||
queryObj = queryObj.groupBy(parsed.groupBy);
|
||||
}
|
||||
return queryObj;
|
||||
}
|
||||
|
||||
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
|
||||
|
||||
if (last !== undefined) {
|
||||
if (cmdOpts.table && cmdOpts.table !== 'transactions') {
|
||||
throw new Error(
|
||||
'--last implies --table transactions. Cannot use with --table ' +
|
||||
cmdOpts.table,
|
||||
);
|
||||
}
|
||||
if (cmdOpts.limit) {
|
||||
throw new Error('--last and --limit are mutually exclusive');
|
||||
}
|
||||
if (!cmdOpts.table) {
|
||||
throw new Error('--table is required (or use --file)');
|
||||
}
|
||||
let queryObj = api.q(cmdOpts.table);
|
||||
|
||||
const table =
|
||||
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
|
||||
if (!table) {
|
||||
throw new Error('--table is required (or use --file or --last)');
|
||||
}
|
||||
|
||||
if (!(table in TABLE_SCHEMA)) {
|
||||
throw new Error(
|
||||
`Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (cmdOpts.where && cmdOpts.filter) {
|
||||
throw new Error('--where and --filter are mutually exclusive');
|
||||
}
|
||||
|
||||
if (cmdOpts.count && cmdOpts.select) {
|
||||
throw new Error('--count and --select are mutually exclusive');
|
||||
}
|
||||
|
||||
let queryObj = api.q(table);
|
||||
|
||||
if (cmdOpts.count) {
|
||||
queryObj = queryObj.calculate({ $count: '*' });
|
||||
} else if (cmdOpts.select) {
|
||||
if (cmdOpts.select) {
|
||||
queryObj = queryObj.select(cmdOpts.select.split(','));
|
||||
} else if (last !== undefined) {
|
||||
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
|
||||
}
|
||||
|
||||
const filterStr = cmdOpts.filter ?? cmdOpts.where;
|
||||
if (filterStr) {
|
||||
queryObj = queryObj.filter(JSON.parse(filterStr));
|
||||
if (cmdOpts.filter) {
|
||||
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
||||
}
|
||||
|
||||
const orderByStr =
|
||||
cmdOpts.orderBy ??
|
||||
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
|
||||
if (orderByStr) {
|
||||
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
|
||||
if (cmdOpts.orderBy) {
|
||||
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
||||
}
|
||||
|
||||
const limitVal =
|
||||
last ??
|
||||
(cmdOpts.limit ? parseIntFlag(cmdOpts.limit, '--limit') : undefined);
|
||||
if (limitVal !== undefined) {
|
||||
queryObj = queryObj.limit(limitVal);
|
||||
}
|
||||
|
||||
if (cmdOpts.offset) {
|
||||
queryObj = queryObj.offset(parseIntFlag(cmdOpts.offset, '--offset'));
|
||||
}
|
||||
|
||||
if (cmdOpts.groupBy) {
|
||||
queryObj = queryObj.groupBy(cmdOpts.groupBy.split(','));
|
||||
if (cmdOpts.limit) {
|
||||
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
||||
}
|
||||
|
||||
return queryObj;
|
||||
}
|
||||
|
||||
const RUN_EXAMPLES = `
|
||||
Examples:
|
||||
# Show last 5 transactions (shortcut)
|
||||
actual query run --last 5
|
||||
|
||||
# Transactions ordered by date descending
|
||||
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
|
||||
|
||||
# Filter with JSON (negative amounts = expenses)
|
||||
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
|
||||
|
||||
# Count transactions
|
||||
actual query run --table transactions --count
|
||||
|
||||
# Group by category (use --file for aggregate expressions)
|
||||
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
|
||||
|
||||
# Pagination
|
||||
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
|
||||
|
||||
# Use --where (alias for --filter)
|
||||
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
|
||||
|
||||
# Read query from a JSON file
|
||||
actual query run --file query.json
|
||||
|
||||
# Pipe query from stdin
|
||||
echo '{"table":"transactions","limit":5}' | actual query run --file -
|
||||
|
||||
Available tables: ${AVAILABLE_TABLES}
|
||||
Use "actual query tables" and "actual query fields <table>" for schema info.
|
||||
|
||||
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
|
||||
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
|
||||
|
||||
export function registerQueryCommand(program: Command) {
|
||||
const query = program
|
||||
.command('query')
|
||||
@@ -265,34 +65,16 @@ export function registerQueryCommand(program: Command) {
|
||||
.description('Execute an AQL query')
|
||||
.option(
|
||||
'--table <table>',
|
||||
'Table to query (use "actual query tables" to list available tables)',
|
||||
'Table to query (transactions, accounts, categories, payees)',
|
||||
)
|
||||
.option('--select <fields>', 'Comma-separated fields to select')
|
||||
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
|
||||
.option(
|
||||
'--where <json>',
|
||||
'Alias for --filter (cannot be used together with --filter)',
|
||||
)
|
||||
.option(
|
||||
'--order-by <fields>',
|
||||
'Fields with optional direction: field1:desc,field2 (default: asc)',
|
||||
)
|
||||
.option('--filter <json>', 'Filter expression as JSON')
|
||||
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
||||
.option('--limit <n>', 'Limit number of results')
|
||||
.option('--offset <n>', 'Skip first N results (for pagination)')
|
||||
.option(
|
||||
'--last <n>',
|
||||
'Show last N transactions (implies --table transactions, --order-by date:desc)',
|
||||
)
|
||||
.option('--count', 'Count matching rows instead of returning them')
|
||||
.option(
|
||||
'--group-by <fields>',
|
||||
'Comma-separated fields to group by (use with aggregate selects)',
|
||||
)
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read full query object from JSON file (use - for stdin)',
|
||||
)
|
||||
.addHelpText('after', RUN_EXAMPLES)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
@@ -305,40 +87,7 @@ export function registerQueryCommand(program: Command) {
|
||||
: buildQueryFromFlags(cmdOpts);
|
||||
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
|
||||
if (cmdOpts.count) {
|
||||
printOutput({ count: result.data }, opts.format);
|
||||
} else {
|
||||
printOutput(result, opts.format);
|
||||
}
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
query
|
||||
.command('tables')
|
||||
.description('List available tables for querying')
|
||||
.action(() => {
|
||||
const opts = program.opts();
|
||||
const tables = Object.keys(TABLE_SCHEMA).map(name => ({ name }));
|
||||
printOutput(tables, opts.format);
|
||||
});
|
||||
|
||||
query
|
||||
.command('fields <table>')
|
||||
.description('List fields for a given table')
|
||||
.action((table: string) => {
|
||||
const opts = program.opts();
|
||||
const schema = TABLE_SCHEMA[table];
|
||||
if (!schema) {
|
||||
throw new Error(
|
||||
`Unknown table "${table}". Available tables: ${Object.keys(TABLE_SCHEMA).join(', ')}`,
|
||||
);
|
||||
}
|
||||
const fields = Object.entries(schema).map(([name, info]) => ({
|
||||
name,
|
||||
type: info.type,
|
||||
...(info.ref ? { ref: info.ref } : {}),
|
||||
}));
|
||||
printOutput(fields, opts.format);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.getPayeeRules({
|
||||
loadedRules = await send('payees-get-rules', {
|
||||
id: payeeId,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -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 { server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.batchChangePayees({
|
||||
await send('payees-batch-change', {
|
||||
updated: [{ id: payee.id, name: editedPayeeName.trim() }],
|
||||
});
|
||||
showUndoNotification({
|
||||
|
||||
@@ -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 { server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.getPayeeRules({
|
||||
const associatedRules: RuleEntity[] = await send('payees-get-rules', {
|
||||
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 server.batchChangePayees({ deleted: [{ id: payee.id }] });
|
||||
await send('payees-batch-change', { deleted: [{ id: payee.id }] });
|
||||
showUndoNotification({
|
||||
message: t('Payee "{{name}}" deleted successfully', {
|
||||
name: payee.name,
|
||||
|
||||
@@ -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, server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.mergePayees({
|
||||
await send('payees-merge', {
|
||||
targetId: targetPayee.id,
|
||||
mergeIds: payees.map(payee => payee.id),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { listen, server } from 'loot-core/platform/client/connection';
|
||||
import { listen, send } 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 server.batchChangePayees(changes);
|
||||
await send('payees-batch-change', changes);
|
||||
queryClient.setQueryData(
|
||||
payeeQueries.listOrphaned().queryKey,
|
||||
existing => applyChanges(changes, existing ?? []),
|
||||
);
|
||||
}}
|
||||
onMerge={async ([targetId, ...mergeIds]) => {
|
||||
await server.mergePayees({ targetId, mergeIds });
|
||||
await send('payees-merge', { targetId, mergeIds });
|
||||
|
||||
const targetIdIsOrphan = orphanedPayees
|
||||
.map(o => o.id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.createPayeeLocation({
|
||||
return await send('payee-location-create', {
|
||||
payeeId,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
@@ -76,18 +76,18 @@ export class SendApiLocationClient implements LocationApiClient {
|
||||
}
|
||||
|
||||
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
|
||||
return await server.getPayeeLocations({ payeeId });
|
||||
return await send('payee-locations-get', { payeeId });
|
||||
}
|
||||
|
||||
async deleteLocation(locationId: string): Promise<void> {
|
||||
await server.deletePayeeLocation({ id: locationId });
|
||||
await send('payee-location-delete', { id: locationId });
|
||||
}
|
||||
|
||||
async getNearbyPayees(
|
||||
coordinates: LocationCoordinates,
|
||||
maxDistance: number,
|
||||
): Promise<NearbyPayeeEntity[]> {
|
||||
const result = await server.getNearbyPayees({
|
||||
const result = await send('payees-get-nearby', {
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
maxDistance,
|
||||
|
||||
@@ -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 { server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.createPayee({
|
||||
const id: PayeeEntity['id'] = await send('payee-create', {
|
||||
name: name.trim(),
|
||||
});
|
||||
return id;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { queryOptions } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { server } from 'loot-core/platform/client/connection';
|
||||
import { send } 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 server.getPayees()) ?? [];
|
||||
const payees: PayeeEntity[] = (await send('payees-get')) ?? [];
|
||||
return translatePayees(payees);
|
||||
},
|
||||
placeholderData: [],
|
||||
@@ -32,7 +32,7 @@ export const payeeQueries = {
|
||||
queryOptions<PayeeEntity[]>({
|
||||
queryKey: [...payeeQueries.lists(), 'common'],
|
||||
queryFn: async () => {
|
||||
const payees: PayeeEntity[] = (await server.getCommonPayees()) ?? [];
|
||||
const payees: PayeeEntity[] = (await send('common-payees-get')) ?? [];
|
||||
return translatePayees(payees);
|
||||
},
|
||||
placeholderData: [],
|
||||
@@ -44,7 +44,7 @@ export const payeeQueries = {
|
||||
queryKey: [...payeeQueries.lists(), 'orphaned'],
|
||||
queryFn: async () => {
|
||||
const payees: Pick<PayeeEntity, 'id'>[] =
|
||||
(await server.getOrphanedPayees()) ?? [];
|
||||
(await send('payees-get-orphaned')) ?? [];
|
||||
return payees;
|
||||
},
|
||||
placeholderData: [],
|
||||
@@ -55,7 +55,7 @@ export const payeeQueries = {
|
||||
queryOptions<Map<PayeeEntity['id'], number>>({
|
||||
queryKey: [...payeeQueries.lists(), 'ruleCounts'],
|
||||
queryFn: async () => {
|
||||
const counts = await server.getPayeeRuleCounts();
|
||||
const counts = await send('payees-get-rule-counts');
|
||||
return new Map(Object.entries(counts ?? {}));
|
||||
},
|
||||
placeholderData: new Map(),
|
||||
|
||||
@@ -101,30 +101,3 @@ or
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
The examples above are shown in JavaScript. If you're using the [CLI tool](../cli.md), you can express many of the same queries with command-line flags. Here's how the JS patterns translate:
|
||||
|
||||
```bash
|
||||
# Select specific fields (JS: .select(['date', 'amount', 'payee.name']))
|
||||
actual query run --table transactions --select "date,amount,payee.name"
|
||||
|
||||
# Filter by condition (JS: .filter({ amount: { $lt: 0 } }))
|
||||
actual query run --table transactions --filter '{"amount":{"$lt":0}}'
|
||||
|
||||
# Order by field descending (JS: .orderBy([{ date: 'desc' }]))
|
||||
actual query run --table transactions --order-by "date:desc"
|
||||
|
||||
# Search by month (JS: .filter({ date: { $transform: '$month', $eq: '2021-01' } }))
|
||||
actual query run --table transactions --filter '{"date":{"$transform":"$month","$eq":"2021-01"}}'
|
||||
|
||||
# Group by payee with sum — use --file for aggregate queries
|
||||
echo '{"table":"transactions","groupBy":["payee.name"],"select":["payee.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
|
||||
|
||||
# Count transactions (JS: .calculate({ $count: '*' }))
|
||||
actual query run --table transactions --count
|
||||
|
||||
# Quick shortcut: last 10 transactions
|
||||
actual query run --last 10
|
||||
```
|
||||
|
||||
@@ -274,80 +274,16 @@ actual schedules delete <id>
|
||||
|
||||
### Query (ActualQL)
|
||||
|
||||
Run queries using [ActualQL](./actual-ql/index.md).
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
| ---------------------- | --------------------------------- |
|
||||
| `query run` | Execute an AQL query |
|
||||
| `query tables` | List available tables |
|
||||
| `query fields <table>` | List fields and types for a table |
|
||||
|
||||
#### `query run` Options
|
||||
|
||||
| Option | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `--table <table>` | Table to query (use `actual query tables` to list) |
|
||||
| `--select <fields>` | Comma-separated fields to select |
|
||||
| `--filter <json>` | Filter as JSON (e.g. `'{"amount":{"$lt":0}}'`) |
|
||||
| `--where <json>` | Alias for `--filter` (cannot be used together) |
|
||||
| `--order-by <fields>` | Fields with optional direction: `field1:desc,field2` (default: asc) |
|
||||
| `--limit <n>` | Limit number of results |
|
||||
| `--offset <n>` | Skip first N results (for pagination) |
|
||||
| `--last <n>` | Show last N transactions (shortcut: implies `--table transactions`, `--order-by date:desc`) |
|
||||
| `--count` | Count matching rows instead of returning them |
|
||||
| `--group-by <fields>` | Comma-separated fields to group by |
|
||||
| `--file <path>` | Read query from JSON file (use `-` for stdin) |
|
||||
|
||||
#### Examples
|
||||
Run queries using [ActualQL](./actual-ql/index.md):
|
||||
|
||||
```bash
|
||||
# Show last 5 transactions (convenience shortcut)
|
||||
actual query run --last 5
|
||||
# Run a query (inline)
|
||||
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||
|
||||
# Override default columns with --last
|
||||
actual query run --last 10 --select "date,amount,notes"
|
||||
|
||||
# Transactions ordered by date descending with limit
|
||||
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
|
||||
|
||||
# Filter with JSON — negative amounts (expenses)
|
||||
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
|
||||
|
||||
# Use --where (alias for --filter, more intuitive for SQL users)
|
||||
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
|
||||
|
||||
# Count all transactions
|
||||
actual query run --table transactions --count
|
||||
|
||||
# Count with a filter
|
||||
actual query run --table transactions --filter '{"category.name":"Groceries"}' --count
|
||||
|
||||
# Group by category with aggregate (use --file for aggregate expressions)
|
||||
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
|
||||
|
||||
# Pagination: skip first 20, show next 10
|
||||
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
|
||||
|
||||
# Multi-field ordering
|
||||
actual query run --table transactions --order-by "date:desc,amount:asc" --limit 10
|
||||
|
||||
# Run a query from a JSON file
|
||||
# Run a query (from file)
|
||||
actual query run --file query.json
|
||||
|
||||
# Pipe query from stdin
|
||||
echo '{"table":"transactions","select":["date","amount"],"limit":5}' | actual query run --file -
|
||||
|
||||
# List available tables
|
||||
actual query tables
|
||||
|
||||
# List fields for a table
|
||||
actual query fields transactions
|
||||
```
|
||||
|
||||
See [ActualQL](./actual-ql/index.md) for full filter/function reference including `$transform`, `$month`, `$year`, and aggregate functions.
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
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 { runMutator } from '../server/mutators';
|
||||
import { runHandler, runMutator } from '../server/mutators';
|
||||
import * as sheet from '../server/sheet';
|
||||
import { batchMessages, setSyncingMode } from '../server/sync';
|
||||
import * as monthUtils from '../shared/months';
|
||||
@@ -85,6 +84,7 @@ 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(app, account, payees, groups) {
|
||||
async function fillChecking(handlers, account, payees, groups) {
|
||||
const { incomePayee, expensePayees, incomeGroup, expenseCategories } =
|
||||
extractCommonThings(payees, groups);
|
||||
const numTransactions = integer(20, 40);
|
||||
@@ -297,13 +297,13 @@ async function fillChecking(app, account, payees, groups) {
|
||||
starting_balance_flag: true,
|
||||
});
|
||||
|
||||
await app.runHandler('transactions-batch-update', {
|
||||
await handlers['transactions-batch-update']({
|
||||
added: transactions,
|
||||
fastMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function fillInvestment(app, account, payees, groups) {
|
||||
async function fillInvestment(handlers, account, payees, groups) {
|
||||
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
|
||||
|
||||
const numTransactions = integer(10, 30);
|
||||
@@ -333,13 +333,13 @@ async function fillInvestment(app, account, payees, groups) {
|
||||
starting_balance_flag: true,
|
||||
});
|
||||
|
||||
await app.runHandler('transactions-batch-update', {
|
||||
await handlers['transactions-batch-update']({
|
||||
added: transactions,
|
||||
fastMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function fillSavings(app, account, payees, groups) {
|
||||
async function fillSavings(handlers, account, payees, groups) {
|
||||
const { incomePayee, expensePayees, incomeGroup, expenseCategories } =
|
||||
extractCommonThings(payees, groups);
|
||||
|
||||
@@ -378,13 +378,13 @@ async function fillSavings(app, account, payees, groups) {
|
||||
starting_balance_flag: true,
|
||||
});
|
||||
|
||||
await app.runHandler('transactions-batch-update', {
|
||||
await handlers['transactions-batch-update']({
|
||||
added: transactions,
|
||||
fastMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function fillMortgage(app, account, payees, groups) {
|
||||
async function fillMortgage(handlers, account, payees, groups) {
|
||||
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
|
||||
|
||||
const numTransactions = integer(7, 10);
|
||||
@@ -415,13 +415,13 @@ async function fillMortgage(app, account, payees, groups) {
|
||||
});
|
||||
}
|
||||
|
||||
await app.runHandler('transactions-batch-update', {
|
||||
await handlers['transactions-batch-update']({
|
||||
added: transactions,
|
||||
fastMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function fillOther(app, account, payees, groups) {
|
||||
async function fillOther(handlers, account, payees, groups) {
|
||||
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
|
||||
|
||||
const numTransactions = integer(3, 6);
|
||||
@@ -453,7 +453,7 @@ async function fillOther(app, account, payees, groups) {
|
||||
});
|
||||
}
|
||||
|
||||
await app.runHandler('transactions-batch-update', {
|
||||
await handlers['transactions-batch-update']({
|
||||
added: transactions,
|
||||
fastMode: true,
|
||||
});
|
||||
@@ -594,7 +594,7 @@ async function createBudget(accounts, payees, groups) {
|
||||
await sheet.waitOnSpreadsheet();
|
||||
}
|
||||
|
||||
export async function createTestBudget(app: App<Handlers>) {
|
||||
export async function createTestBudget(handlers: Handlers) {
|
||||
setSyncingMode('import');
|
||||
|
||||
db.execQuery('PRAGMA journal_mode = OFF');
|
||||
@@ -618,7 +618,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
|
||||
await runMutator(async () => {
|
||||
for (const account of accounts) {
|
||||
account.id = await app['account-create'](account);
|
||||
account.id = await handlers['account-create'](account);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -642,7 +642,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
await runMutator(() =>
|
||||
batchMessages(async () => {
|
||||
for (const newPayee of newPayees) {
|
||||
const id = await app['createPayee']({ name: newPayee.name });
|
||||
const id = await handlers['payee-create']({ name: newPayee.name });
|
||||
payees.push({
|
||||
id,
|
||||
name: newPayee.name,
|
||||
@@ -690,7 +690,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
|
||||
await runMutator(async () => {
|
||||
for (const group of newCategoryGroups) {
|
||||
const groupId = await app['category-group-create']({
|
||||
const groupId = await handlers['category-group-create']({
|
||||
name: group.name,
|
||||
isIncome: group.is_income,
|
||||
});
|
||||
@@ -702,7 +702,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
});
|
||||
|
||||
for (const category of group.categories) {
|
||||
const categoryId = await app['category-create']({
|
||||
const categoryId = await handlers['category-create']({
|
||||
...category,
|
||||
isIncome: category.is_income,
|
||||
groupId,
|
||||
@@ -717,7 +717,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
}
|
||||
});
|
||||
|
||||
const allGroups = (await app['get-categories']()).grouped;
|
||||
const allGroups = (await runHandler(handlers['get-categories'])).grouped;
|
||||
|
||||
setSyncingMode('import');
|
||||
|
||||
@@ -725,26 +725,26 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
batchMessages(async () => {
|
||||
for (const account of accounts) {
|
||||
if (account.name === 'Bank of America') {
|
||||
await fillPrimaryChecking(account, payees, allGroups);
|
||||
await fillPrimaryChecking(handlers, account, payees, allGroups);
|
||||
} else if (
|
||||
account.name === 'Capital One Checking' ||
|
||||
account.name === 'HSBC'
|
||||
) {
|
||||
await fillChecking(app, account, payees, allGroups);
|
||||
await fillChecking(handlers, account, payees, allGroups);
|
||||
} else if (account.name === 'Ally Savings') {
|
||||
await fillSavings(app, account, payees, allGroups);
|
||||
await fillSavings(handlers, account, payees, allGroups);
|
||||
} else if (
|
||||
account.name === 'Vanguard 401k' ||
|
||||
account.name === 'Roth IRA'
|
||||
) {
|
||||
await fillInvestment(app, account, payees, allGroups);
|
||||
await fillInvestment(handlers, account, payees, allGroups);
|
||||
} else if (account.name === 'Mortgage') {
|
||||
await fillMortgage(app, account, payees, allGroups);
|
||||
await fillMortgage(handlers, account, payees, allGroups);
|
||||
} else if (account.name === 'House Asset') {
|
||||
await fillOther(app, account, payees, allGroups);
|
||||
await fillOther(handlers, account, payees, allGroups);
|
||||
} else {
|
||||
console.error('Unknown account name for test budget: ', account.name);
|
||||
await fillChecking(app, account, payees, allGroups);
|
||||
await fillChecking(handlers, account, payees, allGroups);
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -773,7 +773,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
);
|
||||
const lastDeposit = results[0];
|
||||
|
||||
await app['transaction-update']({
|
||||
await runHandler(handlers['transaction-update'], {
|
||||
...lastDeposit,
|
||||
amount: lastDeposit.amount + -primaryBalance + integer(10000, 20000),
|
||||
});
|
||||
@@ -791,7 +791,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
batchMessages(async () => {
|
||||
const account = accounts.find(acc => acc.name === 'Bank of America');
|
||||
|
||||
await app['schedule/create']({
|
||||
await runHandler(handlers['schedule/create'], {
|
||||
schedule: {
|
||||
name: 'Phone bills',
|
||||
posts_transaction: false,
|
||||
@@ -822,7 +822,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
],
|
||||
});
|
||||
|
||||
await app['schedule/create']({
|
||||
await runHandler(handlers['schedule/create'], {
|
||||
schedule: {
|
||||
name: 'Internet bill',
|
||||
posts_transaction: false,
|
||||
@@ -847,7 +847,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
],
|
||||
});
|
||||
|
||||
await app['schedule/create']({
|
||||
await runHandler(handlers['schedule/create'], {
|
||||
schedule: {
|
||||
name: 'Wedding',
|
||||
posts_transaction: false,
|
||||
@@ -868,7 +868,7 @@ export async function createTestBudget(app: App<Handlers>) {
|
||||
],
|
||||
});
|
||||
|
||||
await app['schedule/create']({
|
||||
await runHandler(handlers['schedule/create'], {
|
||||
schedule: {
|
||||
name: 'Utilities',
|
||||
posts_transaction: false,
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import type { Handlers } from '../../../types/handlers';
|
||||
import type { ServerEvents } from '../../../types/server-events';
|
||||
|
||||
/**
|
||||
* 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 declare function init(): Promise<unknown>;
|
||||
export type Init = typeof init;
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,6 @@ const listeners = new Map();
|
||||
let messageQueue = [];
|
||||
|
||||
let globalWorker = null;
|
||||
let initPromise: Promise<T.ServerProxy> | null = null;
|
||||
|
||||
class ReconstructedError extends Error {
|
||||
url: string;
|
||||
@@ -84,28 +83,6 @@ 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.
|
||||
@@ -130,7 +107,7 @@ function connectWorker(worker, onOpen, onError) {
|
||||
globalWorker.postMessage({
|
||||
name: 'client-connected-to-backend',
|
||||
});
|
||||
onOpen(server);
|
||||
onOpen();
|
||||
} else if (msg.type === 'app-init-failure') {
|
||||
globalWorker.postMessage({
|
||||
name: '__app-init-failure-acknowledged',
|
||||
@@ -172,16 +149,11 @@ function connectWorker(worker, onOpen, onError) {
|
||||
}
|
||||
}
|
||||
|
||||
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 init: T.Init = async function () {
|
||||
const worker = await global.Actual.getServerSocket();
|
||||
return new Promise((resolve, reject) =>
|
||||
connectWorker(worker, resolve, reject),
|
||||
);
|
||||
};
|
||||
|
||||
export const send: T.Send = function (
|
||||
|
||||
@@ -9,29 +9,6 @@ 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) {
|
||||
@@ -95,15 +72,12 @@ function connectSocket(onOpen) {
|
||||
messageQueue = [];
|
||||
}
|
||||
|
||||
onOpen(server);
|
||||
onOpen();
|
||||
});
|
||||
}
|
||||
|
||||
export const init: T.Init = function () {
|
||||
if (!initPromise) {
|
||||
initPromise = new Promise(connectSocket);
|
||||
}
|
||||
return initPromise;
|
||||
export const init: T.Init = async function () {
|
||||
return new Promise(connectSocket);
|
||||
};
|
||||
|
||||
export const send: T.Send = function (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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
|
||||
app: App<Handlers>,
|
||||
handlers: Handlers,
|
||||
): void;
|
||||
export type Init = typeof init;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { APIError } from '../../../server/errors';
|
||||
import { isMutating } from '../../../server/mutators';
|
||||
import { isMutating, runHandler } 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, app) {
|
||||
export const init: T.Init = function (_socketName, handlers) {
|
||||
process.parentPort.on('message', ({ data }) => {
|
||||
const { id, name, args, undoTag, catchErrors } = data;
|
||||
|
||||
if (app.hasHandler(name)) {
|
||||
app.runHandler(name, args, { undoTag, name }).then(
|
||||
if (handlers[name]) {
|
||||
runHandler(handlers[name], args, { undoTag, name }).then(
|
||||
result => {
|
||||
if (catchErrors) {
|
||||
result = { data: result, error: null };
|
||||
@@ -30,9 +30,7 @@ export const init: T.Init = function (_socketName, app) {
|
||||
id,
|
||||
result,
|
||||
mutated:
|
||||
isMutating(app.getHandler(name)) &&
|
||||
name !== 'undo' &&
|
||||
name !== 'redo',
|
||||
isMutating(handlers[name]) && name !== 'undo' && name !== 'redo',
|
||||
undoTag,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { APIError } from '../../../server/errors';
|
||||
import { isMutating } from '../../../server/mutators';
|
||||
import { isMutating, runHandler } 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, app) {
|
||||
export const init: T.Init = function (serverChn, handlers) {
|
||||
const serverChannel = serverChn as Window;
|
||||
getGlobalObject().__globalServerChannel = serverChannel;
|
||||
|
||||
@@ -54,14 +54,14 @@ export const init: T.Init = function (serverChn, app) {
|
||||
|
||||
const { id, name, args, undoTag, catchErrors } = msg;
|
||||
|
||||
if (app.hasHandler(name)) {
|
||||
app.runHandler(name, args, { undoTag, name }).then(
|
||||
if (handlers[name]) {
|
||||
runHandler(handlers[name], args, { undoTag, name }).then(
|
||||
result => {
|
||||
serverChannel.postMessage({
|
||||
type: 'reply',
|
||||
id,
|
||||
result: catchErrors ? { data: result, error: null } : result,
|
||||
mutated: isMutating(app.getHandler(name)),
|
||||
mutated: isMutating(handlers[name]),
|
||||
undoTag,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('./sync', async () => ({
|
||||
syncAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
const simpleFinBatchSyncHandler = app['simplefin-batch-sync'];
|
||||
const simpleFinBatchSyncHandler = app.handlers['simplefin-batch-sync'];
|
||||
|
||||
function insertBank(bank: { id: string; bank_id: string; name: string }) {
|
||||
db.runQuery(
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
PostError,
|
||||
TransactionError,
|
||||
} from '../errors';
|
||||
import { mainApp } from '../main';
|
||||
import { app as mainApp } from '../main-app';
|
||||
import { mutator } from '../mutators';
|
||||
import { get, post } from '../post';
|
||||
import { getServer } from '../server-config';
|
||||
@@ -495,7 +495,7 @@ async function closeAccount({
|
||||
);
|
||||
}
|
||||
|
||||
await mainApp['transaction-add']({
|
||||
await mainApp.handlers['transaction-add']({
|
||||
id: uuidv4(),
|
||||
payee: transferPayee.id,
|
||||
amount: -balance,
|
||||
|
||||
@@ -1,43 +1,36 @@
|
||||
// @ts-strict-ignore
|
||||
import { app as apiApp } from './api';
|
||||
import { mainApp } from './main';
|
||||
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);
|
||||
|
||||
describe('API app', () => {
|
||||
describe('api/bank-sync', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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}`);
|
||||
},
|
||||
);
|
||||
handlers['accounts-bank-sync'] = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ errors: [] });
|
||||
|
||||
await apiApp['api/bank-sync']({ accountId: 'account1' });
|
||||
expect(mainApp.runHandler.bind(mainApp)).toHaveBeenCalledWith(
|
||||
'accounts-bank-sync',
|
||||
{
|
||||
ids: ['account1'],
|
||||
},
|
||||
);
|
||||
await handlers['api/bank-sync']({ accountId: 'account1' });
|
||||
expect(handlers['accounts-bank-sync']).toHaveBeenCalledWith({
|
||||
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}`);
|
||||
},
|
||||
);
|
||||
it('should handle errors in non batch sync', async () => {
|
||||
handlers['accounts-bank-sync'] = vi.fn().mockResolvedValue({
|
||||
errors: ['connection-failed'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
apiApp['api/bank-sync']({ accountId: 'account2' }),
|
||||
).rejects.toThrow('connection-failed');
|
||||
handlers['api/bank-sync']({ accountId: 'account2' }),
|
||||
).rejects.toThrow('Bank sync error: connection-failed');
|
||||
|
||||
expect(getBankSyncError).toHaveBeenCalledWith('connection-failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
updateTransaction,
|
||||
} from '../shared/transactions';
|
||||
import { integerToAmount } from '../shared/util';
|
||||
import type { ApiHandlers } from '../types/api-handlers';
|
||||
import type { Handlers } from '../types/handlers';
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryGroupEntity,
|
||||
ScheduleEntity,
|
||||
} from '../types/models';
|
||||
import type { ServerHandlers } from '../types/server-handlers';
|
||||
|
||||
import { addTransactions } from './accounts/sync';
|
||||
import {
|
||||
@@ -36,13 +37,11 @@ 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';
|
||||
@@ -83,7 +82,7 @@ function withMutation<Params extends Array<unknown>, ReturnType>(
|
||||
};
|
||||
}
|
||||
|
||||
const handlers = {} as ApiHandlers;
|
||||
let handlers = {} as unknown as Handlers;
|
||||
|
||||
async function validateMonth(month) {
|
||||
if (!month.match(/^\d{4}-\d{2}$/)) {
|
||||
@@ -91,7 +90,7 @@ async function validateMonth(month) {
|
||||
}
|
||||
|
||||
if (!IMPORT_MODE) {
|
||||
const { start, end } = await mainApp['get-budget-bounds']();
|
||||
const { start, end } = await handlers['get-budget-bounds']();
|
||||
const range = monthUtils.range(start, end);
|
||||
if (!range.includes(month)) {
|
||||
throw APIError('No budget exists for month: ' + month);
|
||||
@@ -163,7 +162,7 @@ handlers['api/load-budget'] = async function ({ id }) {
|
||||
|
||||
if (currentId !== id) {
|
||||
connection.send('start-load');
|
||||
const { error } = await mainApp['load-budget']({ id });
|
||||
const { error } = await handlers['load-budget']({ id });
|
||||
|
||||
if (!error) {
|
||||
connection.send('finish-load');
|
||||
@@ -178,16 +177,16 @@ handlers['api/load-budget'] = async function ({ id }) {
|
||||
handlers['api/download-budget'] = async function ({ syncId, password }) {
|
||||
const { id: currentId } = prefs.getPrefs() || {};
|
||||
if (currentId) {
|
||||
await mainApp['close-budget']();
|
||||
await handlers['close-budget']();
|
||||
}
|
||||
|
||||
const budgets = await mainApp['get-budgets']();
|
||||
const budgets = await handlers['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 mainApp['get-remote-files']();
|
||||
const files = await handlers['get-remote-files']();
|
||||
if (!files) {
|
||||
throw new Error('Could not get remote files');
|
||||
}
|
||||
@@ -211,7 +210,7 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await mainApp['key-test']({
|
||||
const result = await handlers['key-test']({
|
||||
cloudFileId: remoteBudget ? remoteBudget.fileId : localBudget.cloudFileId,
|
||||
password,
|
||||
});
|
||||
@@ -222,8 +221,8 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
|
||||
|
||||
// Sync the local budget file
|
||||
if (localBudget) {
|
||||
await mainApp['load-budget']({ id: localBudget.id });
|
||||
const result = await mainApp['sync-budget']();
|
||||
await handlers['load-budget']({ id: localBudget.id });
|
||||
const result = await handlers['sync-budget']();
|
||||
if (result.error) {
|
||||
throw new Error(getSyncError(result.error.reason, localBudget.id));
|
||||
}
|
||||
@@ -231,19 +230,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 mainApp['download-budget']({
|
||||
const result = await handlers['download-budget']({
|
||||
cloudFileId: remoteBudget.fileId,
|
||||
});
|
||||
if (result.error) {
|
||||
logger.log('Full error details', result.error);
|
||||
throw new Error(getDownloadError(result.error));
|
||||
}
|
||||
await mainApp['load-budget']({ id: result.id });
|
||||
await handlers['load-budget']({ id: result.id });
|
||||
};
|
||||
|
||||
handlers['api/get-budgets'] = async function () {
|
||||
const budgets = await mainApp['get-budgets']();
|
||||
const files = (await mainApp['get-remote-files']()) || [];
|
||||
const budgets = await handlers['get-budgets']();
|
||||
const files = (await handlers['get-remote-files']()) || [];
|
||||
return [
|
||||
...budgets.map(file => budgetModel.toExternal(file)),
|
||||
...files.map(file => remoteFileModel.toExternal(file)).filter(file => file),
|
||||
@@ -252,7 +251,7 @@ handlers['api/get-budgets'] = async function () {
|
||||
|
||||
handlers['api/sync'] = async function () {
|
||||
const { id } = prefs.getPrefs();
|
||||
const result = await mainApp['sync-budget']();
|
||||
const result = await handlers['sync-budget']();
|
||||
if (result.error) {
|
||||
throw new Error(getSyncError(result.error.reason, id));
|
||||
}
|
||||
@@ -263,13 +262,13 @@ handlers['api/bank-sync'] = async function (args) {
|
||||
const allErrors = [];
|
||||
|
||||
if (!batchSync) {
|
||||
const { errors } = await mainApp['accounts-bank-sync']({
|
||||
const { errors } = await handlers['accounts-bank-sync']({
|
||||
ids: [args.accountId],
|
||||
});
|
||||
|
||||
allErrors.push(...errors);
|
||||
} else {
|
||||
const accountsData = await mainApp['accounts-get']();
|
||||
const accountsData = await handlers['accounts-get']();
|
||||
const accountIdsToSync = accountsData.map(a => a.id);
|
||||
const simpleFinAccounts = accountsData.filter(
|
||||
a => a.account_sync_source === 'simpleFin',
|
||||
@@ -277,14 +276,14 @@ handlers['api/bank-sync'] = async function (args) {
|
||||
const simpleFinAccountIds = simpleFinAccounts.map(a => a.id);
|
||||
|
||||
if (simpleFinAccounts.length > 1) {
|
||||
const res = await mainApp['simplefin-batch-sync']({
|
||||
const res = await handlers['simplefin-batch-sync']({
|
||||
ids: simpleFinAccountIds,
|
||||
});
|
||||
|
||||
res.forEach(a => allErrors.push(...a.res.errors));
|
||||
}
|
||||
|
||||
const { errors } = await mainApp['accounts-bank-sync']({
|
||||
const { errors } = await handlers['accounts-bank-sync']({
|
||||
ids: accountIdsToSync.filter(a => !simpleFinAccountIds.includes(a)),
|
||||
});
|
||||
|
||||
@@ -299,10 +298,10 @@ handlers['api/bank-sync'] = async function (args) {
|
||||
|
||||
handlers['api/start-import'] = async function ({ budgetName }) {
|
||||
// Notify UI to close budget
|
||||
await mainApp['close-budget']();
|
||||
await handlers['close-budget']();
|
||||
|
||||
// Create the budget
|
||||
await mainApp['create-budget']({ budgetName, avoidUpload: true });
|
||||
await handlers['create-budget']({ budgetName, avoidUpload: true });
|
||||
|
||||
// Clear out the default expense categories
|
||||
db.runQuery('DELETE FROM categories WHERE is_income = 0');
|
||||
@@ -324,10 +323,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 mainApp['close-budget']();
|
||||
await mainApp['load-budget']({ id });
|
||||
await handlers['close-budget']();
|
||||
await handlers['load-budget']({ id });
|
||||
|
||||
await mainApp['get-budget-bounds']();
|
||||
await handlers['get-budget-bounds']();
|
||||
await sheet.waitOnSpreadsheet();
|
||||
|
||||
await cloudStorage.upload().catch(() => {
|
||||
@@ -344,8 +343,8 @@ handlers['api/abort-import'] = async function () {
|
||||
|
||||
const { id } = prefs.getPrefs();
|
||||
|
||||
await mainApp['close-budget']();
|
||||
await mainApp['delete-budget']({ id });
|
||||
await handlers['close-budget']();
|
||||
await handlers['delete-budget']({ id });
|
||||
connection.send('show-budgets');
|
||||
}
|
||||
|
||||
@@ -359,7 +358,7 @@ handlers['api/query'] = async function ({ query }) {
|
||||
|
||||
handlers['api/budget-months'] = async function () {
|
||||
checkFileOpen();
|
||||
const { start, end } = await mainApp['get-budget-bounds']();
|
||||
const { start, end } = await handlers['get-budget-bounds']();
|
||||
return monthUtils.range(start, end);
|
||||
};
|
||||
|
||||
@@ -429,7 +428,7 @@ handlers['api/budget-set-amount'] = withMutation(async function ({
|
||||
amount,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['budget/budget-amount']({
|
||||
return handlers['budget/budget-amount']({
|
||||
month,
|
||||
category: categoryId,
|
||||
amount,
|
||||
@@ -444,7 +443,7 @@ handlers['api/budget-set-carryover'] = withMutation(async function ({
|
||||
checkFileOpen();
|
||||
await validateMonth(month);
|
||||
await validateExpenseCategory('budget-set-carryover', categoryId);
|
||||
return mainApp['budget/set-carryover']({
|
||||
return handlers['budget/set-carryover']({
|
||||
startMonth: month,
|
||||
category: categoryId,
|
||||
flag,
|
||||
@@ -460,7 +459,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 mainApp['budget/hold-for-next-month']({
|
||||
return handlers['budget/hold-for-next-month']({
|
||||
month,
|
||||
amount,
|
||||
});
|
||||
@@ -469,7 +468,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 mainApp['budget/reset-hold']({ month });
|
||||
return handlers['budget/reset-hold']({ month });
|
||||
});
|
||||
|
||||
handlers['api/transactions-export'] = async function ({
|
||||
@@ -479,7 +478,7 @@ handlers['api/transactions-export'] = async function ({
|
||||
accounts,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['transactions-export']({
|
||||
return handlers['transactions-export']({
|
||||
transactions,
|
||||
categoryGroups,
|
||||
payees,
|
||||
@@ -494,7 +493,7 @@ handlers['api/transactions-import'] = withMutation(async function ({
|
||||
opts,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['transactions-import']({
|
||||
return handlers['transactions-import']({
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview,
|
||||
@@ -552,7 +551,7 @@ handlers['api/transaction-update'] = withMutation(async function ({
|
||||
}
|
||||
|
||||
const { diff } = updateTransaction(transactions, { id, ...fields });
|
||||
return mainApp['transactions-batch-update'](diff)['updated'];
|
||||
return handlers['transactions-batch-update'](diff)['updated'];
|
||||
});
|
||||
|
||||
handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
|
||||
@@ -567,12 +566,12 @@ handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
|
||||
}
|
||||
|
||||
const { diff } = deleteTransaction(transactions, id);
|
||||
return mainApp['transactions-batch-update'](diff)['deleted'];
|
||||
return handlers['transactions-batch-update'](diff)['deleted'];
|
||||
});
|
||||
|
||||
handlers['api/accounts-get'] = async function () {
|
||||
checkFileOpen();
|
||||
const accounts: AccountEntity[] = await mainApp['accounts-get']();
|
||||
const accounts: AccountEntity[] = await handlers['accounts-get']();
|
||||
return accounts.map(account => accountModel.toExternal(account));
|
||||
};
|
||||
|
||||
@@ -581,7 +580,7 @@ handlers['api/account-create'] = withMutation(async function ({
|
||||
initialBalance = null,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['account-create']({
|
||||
return handlers['account-create']({
|
||||
name: account.name,
|
||||
offBudget: account.offbudget,
|
||||
closed: account.closed,
|
||||
@@ -593,7 +592,7 @@ handlers['api/account-create'] = withMutation(async function ({
|
||||
|
||||
handlers['api/account-update'] = withMutation(async function ({ id, fields }) {
|
||||
checkFileOpen();
|
||||
await mainApp['account-update']({ id, ...accountModel.fromExternal(fields) });
|
||||
return db.updateAccount({ id, ...accountModel.fromExternal(fields) });
|
||||
});
|
||||
|
||||
handlers['api/account-close'] = withMutation(async function ({
|
||||
@@ -602,7 +601,7 @@ handlers['api/account-close'] = withMutation(async function ({
|
||||
transferCategoryId,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['account-close']({
|
||||
return handlers['account-close']({
|
||||
id,
|
||||
transferAccountId,
|
||||
categoryId: transferCategoryId,
|
||||
@@ -611,12 +610,12 @@ handlers['api/account-close'] = withMutation(async function ({
|
||||
|
||||
handlers['api/account-reopen'] = withMutation(async function ({ id }) {
|
||||
checkFileOpen();
|
||||
return mainApp['account-reopen']({ id });
|
||||
return handlers['account-reopen']({ id });
|
||||
});
|
||||
|
||||
handlers['api/account-delete'] = withMutation(async function ({ id }) {
|
||||
checkFileOpen();
|
||||
return mainApp['account-close']({ id, forced: true });
|
||||
return handlers['account-close']({ id, forced: true });
|
||||
});
|
||||
|
||||
handlers['api/account-balance'] = withMutation(async function ({
|
||||
@@ -624,14 +623,14 @@ handlers['api/account-balance'] = withMutation(async function ({
|
||||
cutoff = new Date(),
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['account-balance']({ id, cutoff });
|
||||
return handlers['account-balance']({ id, cutoff });
|
||||
});
|
||||
|
||||
handlers['api/categories-get'] = async function ({
|
||||
grouped,
|
||||
}: { grouped? } = {}) {
|
||||
checkFileOpen();
|
||||
const result = await mainApp['get-categories']();
|
||||
const result = await handlers['get-categories']();
|
||||
return grouped
|
||||
? result.grouped.map(group => categoryGroupModel.toExternal(group))
|
||||
: result.list.map(category => categoryModel.toExternal(category));
|
||||
@@ -639,7 +638,7 @@ handlers['api/categories-get'] = async function ({
|
||||
|
||||
handlers['api/category-groups-get'] = async function () {
|
||||
checkFileOpen();
|
||||
const groups = await mainApp['get-category-groups']();
|
||||
const groups = await handlers['get-category-groups']();
|
||||
return groups.map(group => categoryGroupModel.toExternal(group));
|
||||
};
|
||||
|
||||
@@ -647,7 +646,7 @@ handlers['api/category-group-create'] = withMutation(async function ({
|
||||
group,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['category-group-create']({
|
||||
return handlers['category-group-create']({
|
||||
name: group.name,
|
||||
hidden: group.hidden,
|
||||
});
|
||||
@@ -658,7 +657,7 @@ handlers['api/category-group-update'] = withMutation(async function ({
|
||||
fields,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['category-group-update']({
|
||||
return handlers['category-group-update']({
|
||||
id,
|
||||
...categoryGroupModel.fromExternal(fields),
|
||||
});
|
||||
@@ -669,7 +668,7 @@ handlers['api/category-group-delete'] = withMutation(async function ({
|
||||
transferCategoryId,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['category-group-delete']({
|
||||
return handlers['category-group-delete']({
|
||||
id,
|
||||
transferId: transferCategoryId,
|
||||
});
|
||||
@@ -677,7 +676,7 @@ handlers['api/category-group-delete'] = withMutation(async function ({
|
||||
|
||||
handlers['api/category-create'] = withMutation(async function ({ category }) {
|
||||
checkFileOpen();
|
||||
return mainApp['category-create']({
|
||||
return handlers['category-create']({
|
||||
name: category.name,
|
||||
groupId: category.group_id,
|
||||
isIncome: category.is_income,
|
||||
@@ -687,7 +686,7 @@ handlers['api/category-create'] = withMutation(async function ({ category }) {
|
||||
|
||||
handlers['api/category-update'] = withMutation(async function ({ id, fields }) {
|
||||
checkFileOpen();
|
||||
return mainApp['category-update']({
|
||||
return handlers['category-update']({
|
||||
id,
|
||||
...categoryModel.fromExternal(fields),
|
||||
});
|
||||
@@ -698,7 +697,7 @@ handlers['api/category-delete'] = withMutation(async function ({
|
||||
transferCategoryId,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp['category-delete']({
|
||||
return handlers['category-delete']({
|
||||
id,
|
||||
transferId: transferCategoryId,
|
||||
});
|
||||
@@ -706,31 +705,31 @@ handlers['api/category-delete'] = withMutation(async function ({
|
||||
|
||||
handlers['api/common-payees-get'] = async function () {
|
||||
checkFileOpen();
|
||||
const payees = await mainApp.getCommonPayees();
|
||||
const payees = await handlers['common-payees-get']();
|
||||
return payees.map(payee => payeeModel.toExternal(payee));
|
||||
};
|
||||
|
||||
handlers['api/payees-get'] = async function () {
|
||||
checkFileOpen();
|
||||
const payees = await mainApp.getPayees();
|
||||
const payees = await handlers['payees-get']();
|
||||
return payees.map(payee => payeeModel.toExternal(payee));
|
||||
};
|
||||
|
||||
handlers['api/payee-create'] = withMutation(async function ({ payee }) {
|
||||
checkFileOpen();
|
||||
return mainApp.createPayee({ name: payee.name });
|
||||
return handlers['payee-create']({ name: payee.name });
|
||||
});
|
||||
|
||||
handlers['api/payee-update'] = withMutation(async function ({ id, fields }) {
|
||||
checkFileOpen();
|
||||
return mainApp.batchChangePayees({
|
||||
return handlers['payees-batch-change']({
|
||||
updated: [{ id, ...payeeModel.fromExternal(fields) }],
|
||||
});
|
||||
});
|
||||
|
||||
handlers['api/payee-delete'] = withMutation(async function ({ id }) {
|
||||
checkFileOpen();
|
||||
return mainApp.batchChangePayees({ deleted: [{ id }] });
|
||||
return handlers['payees-batch-change']({ deleted: [{ id }] });
|
||||
});
|
||||
|
||||
handlers['api/payees-merge'] = withMutation(async function ({
|
||||
@@ -738,18 +737,18 @@ handlers['api/payees-merge'] = withMutation(async function ({
|
||||
mergeIds,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp.mergePayees({ targetId, mergeIds });
|
||||
return handlers['payees-merge']({ targetId, mergeIds });
|
||||
});
|
||||
|
||||
handlers['api/tags-get'] = async function () {
|
||||
checkFileOpen();
|
||||
const tags = await mainApp['tags-get']();
|
||||
const tags = await handlers['tags-get']();
|
||||
return tags.map(tag => tagModel.toExternal(tag));
|
||||
};
|
||||
|
||||
handlers['api/tag-create'] = withMutation(async function ({ tag }) {
|
||||
checkFileOpen();
|
||||
const result = await mainApp['tags-create']({
|
||||
const result = await handlers['tags-create']({
|
||||
tag: tag.tag,
|
||||
color: tag.color,
|
||||
description: tag.description,
|
||||
@@ -759,12 +758,12 @@ handlers['api/tag-create'] = withMutation(async function ({ tag }) {
|
||||
|
||||
handlers['api/tag-update'] = withMutation(async function ({ id, fields }) {
|
||||
checkFileOpen();
|
||||
await mainApp['tags-update']({ id, ...tagModel.fromExternal(fields) });
|
||||
await handlers['tags-update']({ id, ...tagModel.fromExternal(fields) });
|
||||
});
|
||||
|
||||
handlers['api/tag-delete'] = withMutation(async function ({ id }) {
|
||||
checkFileOpen();
|
||||
await mainApp['tags-delete']({ id });
|
||||
await handlers['tags-delete']({ id });
|
||||
});
|
||||
|
||||
handlers['api/payee-location-create'] = withMutation(async function ({
|
||||
@@ -773,17 +772,17 @@ handlers['api/payee-location-create'] = withMutation(async function ({
|
||||
longitude,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp.createPayeeLocation({ payeeId, latitude, longitude });
|
||||
return handlers['payee-location-create']({ payeeId, latitude, longitude });
|
||||
});
|
||||
|
||||
handlers['api/payee-locations-get'] = async function ({ payeeId }) {
|
||||
checkFileOpen();
|
||||
return mainApp.getPayeeLocations({ payeeId });
|
||||
return handlers['payee-locations-get']({ payeeId });
|
||||
};
|
||||
|
||||
handlers['api/payee-location-delete'] = withMutation(async function ({ id }) {
|
||||
checkFileOpen();
|
||||
return mainApp.deletePayeeLocation({ id });
|
||||
return handlers['payee-location-delete']({ id });
|
||||
});
|
||||
|
||||
handlers['api/payees-get-nearby'] = async function ({
|
||||
@@ -792,22 +791,22 @@ handlers['api/payees-get-nearby'] = async function ({
|
||||
maxDistance,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return mainApp.getNearbyPayees({ latitude, longitude, maxDistance });
|
||||
return handlers['payees-get-nearby']({ latitude, longitude, maxDistance });
|
||||
};
|
||||
|
||||
handlers['api/rules-get'] = async function () {
|
||||
checkFileOpen();
|
||||
return mainApp['rules-get']();
|
||||
return handlers['rules-get']();
|
||||
};
|
||||
|
||||
handlers['api/payee-rules-get'] = async function ({ id }) {
|
||||
checkFileOpen();
|
||||
return mainApp.getPayeeRules({ id });
|
||||
return handlers['payees-get-rules']({ id });
|
||||
};
|
||||
|
||||
handlers['api/rule-create'] = withMutation(async function ({ rule }) {
|
||||
checkFileOpen();
|
||||
const addedRule = await mainApp['rule-add'](rule);
|
||||
const addedRule = await handlers['rule-add'](rule);
|
||||
|
||||
if ('error' in addedRule) {
|
||||
throw APIError('Failed creating a new rule', addedRule.error);
|
||||
@@ -818,7 +817,7 @@ handlers['api/rule-create'] = withMutation(async function ({ rule }) {
|
||||
|
||||
handlers['api/rule-update'] = withMutation(async function ({ rule }) {
|
||||
checkFileOpen();
|
||||
const updatedRule = await mainApp['rule-update'](rule);
|
||||
const updatedRule = await handlers['rule-update'](rule);
|
||||
|
||||
if ('error' in updatedRule) {
|
||||
throw APIError('Failed updating the rule', updatedRule.error);
|
||||
@@ -829,7 +828,7 @@ handlers['api/rule-update'] = withMutation(async function ({ rule }) {
|
||||
|
||||
handlers['api/rule-delete'] = withMutation(async function (id) {
|
||||
checkFileOpen();
|
||||
return mainApp['rule-delete'](id);
|
||||
return handlers['rule-delete'](id);
|
||||
});
|
||||
|
||||
handlers['api/schedules-get'] = async function () {
|
||||
@@ -848,7 +847,7 @@ handlers['api/schedule-create'] = withMutation(async function (
|
||||
name: internalSchedule.name,
|
||||
posts_transaction: internalSchedule.posts_transaction,
|
||||
};
|
||||
return mainApp['schedule/create']({
|
||||
return handlers['schedule/create']({
|
||||
schedule: partialSchedule,
|
||||
conditions: internalSchedule._conditions,
|
||||
});
|
||||
@@ -982,7 +981,7 @@ handlers['api/schedule-update'] = withMutation(async function ({
|
||||
}
|
||||
|
||||
if (conditionsUpdated) {
|
||||
return mainApp['schedule/update']({
|
||||
return handlers['schedule/update']({
|
||||
schedule: {
|
||||
id: sched.id,
|
||||
posts_transaction: sched.posts_transaction,
|
||||
@@ -998,7 +997,7 @@ handlers['api/schedule-update'] = withMutation(async function ({
|
||||
|
||||
handlers['api/schedule-delete'] = withMutation(async function (id: string) {
|
||||
checkFileOpen();
|
||||
return mainApp['schedule/delete']({ id });
|
||||
return handlers['schedule/delete']({ id });
|
||||
});
|
||||
|
||||
handlers['api/get-id-by-name'] = async function ({ type, name }) {
|
||||
@@ -1021,7 +1020,11 @@ handlers['api/get-id-by-name'] = async function ({ type, name }) {
|
||||
|
||||
handlers['api/get-server-version'] = async function () {
|
||||
checkFileOpen();
|
||||
return mainApp['get-server-version']();
|
||||
return handlers['get-server-version']();
|
||||
};
|
||||
|
||||
export const app = createApp(handlers);
|
||||
export function installAPI(serverHandlers: ServerHandlers) {
|
||||
const merged = Object.assign({}, serverHandlers, handlers);
|
||||
handlers = merged as Handlers;
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ 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
|
||||
@@ -20,32 +18,22 @@ type Events = {
|
||||
type UnlistenService = () => void;
|
||||
type Service = () => UnlistenService;
|
||||
|
||||
export class App<THandlers> {
|
||||
private handlers: THandlers;
|
||||
private services: Service[];
|
||||
private unlistenServices: UnlistenService[];
|
||||
class App<Handlers> {
|
||||
events: Emitter<Events>;
|
||||
handlers: Handlers;
|
||||
services: Service[];
|
||||
unlistenServices: UnlistenService[];
|
||||
|
||||
readonly events: Emitter<Events>;
|
||||
|
||||
constructor(handlers?: THandlers) {
|
||||
this.handlers = {} as THandlers;
|
||||
constructor() {
|
||||
this.handlers = {} as Handlers;
|
||||
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 THandlers>(
|
||||
method<Name extends string & keyof Handlers>(
|
||||
name: Name,
|
||||
func: THandlers[Name],
|
||||
func: Handlers[Name],
|
||||
) {
|
||||
if (this.handlers[name] != null) {
|
||||
throw new Error(
|
||||
@@ -62,7 +50,7 @@ export class App<THandlers> {
|
||||
combine(...apps) {
|
||||
for (const app of apps) {
|
||||
Object.keys(app.handlers).forEach(name => {
|
||||
this.method(name as string & keyof THandlers, app.handlers[name]);
|
||||
this.method(name as string & keyof Handlers, app.handlers[name]);
|
||||
});
|
||||
|
||||
app.services.forEach(service => {
|
||||
@@ -96,50 +84,8 @@ export class App<THandlers> {
|
||||
});
|
||||
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<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;
|
||||
export function createApp<T>() {
|
||||
return new App<T>();
|
||||
}
|
||||
|
||||
@@ -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 { mainApp } from '../main';
|
||||
import { app as mainApp } from '../main-app';
|
||||
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);
|
||||
await createTestBudget(mainApp.handlers);
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -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 { mainApp } from '../main';
|
||||
import { handlers } 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 mainApp['close-budget']();
|
||||
await handlers['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 mainApp['load-budget']({ id });
|
||||
await mainApp['get-budget-bounds']();
|
||||
await handlers['load-budget']({ id });
|
||||
await handlers['get-budget-bounds']();
|
||||
await waitOnSpreadsheet();
|
||||
await cloudStorage.upload().catch(() => {
|
||||
// Ignore errors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { logger } from '../../platform/server/log';
|
||||
import { mainApp } from '../main';
|
||||
import { handlers } from '../main';
|
||||
|
||||
import { importActual } from './actual';
|
||||
import * as YNAB4 from './ynab4';
|
||||
@@ -42,17 +42,17 @@ export async function handleBudgetImport(
|
||||
}
|
||||
|
||||
try {
|
||||
await mainApp['api/start-import']({ budgetName });
|
||||
await handlers['api/start-import']({ budgetName });
|
||||
} catch (e) {
|
||||
logger.error('failed to start import', e);
|
||||
return { error: 'unknown' };
|
||||
}
|
||||
await importer.doImport(data);
|
||||
} catch (e) {
|
||||
await mainApp['api/abort-import']();
|
||||
await handlers['api/abort-import']();
|
||||
logger.error('failed to run import', e);
|
||||
return { error: 'unknown' };
|
||||
}
|
||||
|
||||
await mainApp['api/finish-import']();
|
||||
await handlers['api/finish-import']();
|
||||
}
|
||||
|
||||
@@ -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 { mainApp } from '../main';
|
||||
import { send } from '../main-app';
|
||||
|
||||
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 mainApp['api/account-create']({
|
||||
const id = await send('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 mainApp['api/category-group-create']({
|
||||
const id = await send('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 mainApp['notes-save']({
|
||||
void send('notes-save', {
|
||||
id,
|
||||
note: masterCategory.note,
|
||||
});
|
||||
@@ -89,7 +89,7 @@ async function importCategories(
|
||||
categoryName = categoryNameParts.join('/').trim();
|
||||
}
|
||||
|
||||
const id = await mainApp['api/category-create']({
|
||||
const id = await send('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 mainApp['notes-save']({
|
||||
void send('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 mainApp['api/payee-create']({
|
||||
const id = await send('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 mainApp['api/categories-get']({
|
||||
const categories = await send('api/categories-get', {
|
||||
grouped: false,
|
||||
});
|
||||
const incomeCategoryId: string = categories.find(
|
||||
cat => cat.name === 'Income',
|
||||
).id;
|
||||
const accounts = await mainApp['api/accounts-get']();
|
||||
const payees = await mainApp['api/payees-get']();
|
||||
const accounts = await send('api/accounts-get');
|
||||
const payees = await send('api/payees-get');
|
||||
|
||||
function getCategory(id: string) {
|
||||
if (id == null || id === 'Category/__Split__') {
|
||||
@@ -245,7 +245,7 @@ async function importTransactions(
|
||||
})
|
||||
.filter(x => x);
|
||||
|
||||
await mainApp['api/transactions-add']({
|
||||
await send('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 mainApp['api/batch-budget-start']();
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
for (const budget of budgets) {
|
||||
const filled = fillInBudgets(
|
||||
@@ -308,20 +308,20 @@ async function importBudgets(
|
||||
return;
|
||||
}
|
||||
|
||||
await mainApp['api/budget-set-amount']({
|
||||
await send('api/budget-set-amount', {
|
||||
month,
|
||||
categoryId: catId,
|
||||
amount,
|
||||
});
|
||||
|
||||
if (catBudget.overspendingHandling === 'AffectsBuffer') {
|
||||
await mainApp['api/budget-set-carryover']({
|
||||
await send('api/budget-set-carryover', {
|
||||
month,
|
||||
categoryId: catId,
|
||||
flag: false,
|
||||
});
|
||||
} else if (catBudget.overspendingHandling === 'Confined') {
|
||||
await mainApp['api/budget-set-carryover']({
|
||||
await send('api/budget-set-carryover', {
|
||||
month,
|
||||
categoryId: catId,
|
||||
flag: true,
|
||||
@@ -331,7 +331,7 @@ async function importBudgets(
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await mainApp['api/batch-budget-end']();
|
||||
await send('api/batch-budget-end');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ 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 { aqlQuery } from '../aql';
|
||||
import { mainApp } from '../main';
|
||||
import { send } from '../main-app';
|
||||
import { ruleModel } from '../transactions/transaction-rules';
|
||||
|
||||
import type {
|
||||
@@ -272,7 +271,7 @@ function importAccounts(data: Budget, entityIdMap: Map<string, string>) {
|
||||
return Promise.all(
|
||||
data.accounts.map(async account => {
|
||||
if (!account.deleted) {
|
||||
const id = await mainApp['api/account-create']({
|
||||
const id = await send('api/account-create', {
|
||||
account: {
|
||||
name: account.name,
|
||||
offbudget: account.on_budget ? false : true,
|
||||
@@ -292,7 +291,7 @@ async function importCategories(
|
||||
// Hidden categories are put in its own group by YNAB,
|
||||
// so it's already handled.
|
||||
|
||||
const categories = await mainApp['api/categories-get']({
|
||||
const categories = await send('api/categories-get', {
|
||||
grouped: false,
|
||||
});
|
||||
const incomeCatId = findIdByName(categories, 'Income');
|
||||
@@ -337,7 +336,7 @@ async function importCategories(
|
||||
while (true) {
|
||||
const name = count === 0 ? baseName : `${baseName} (${count})`;
|
||||
try {
|
||||
const id = await mainApp['api/category-group-create']({
|
||||
const id = await send('api/category-group-create', {
|
||||
group: { ...params, name },
|
||||
});
|
||||
return { id, name };
|
||||
@@ -362,7 +361,7 @@ async function importCategories(
|
||||
while (true) {
|
||||
const name = count === 0 ? baseName : `${baseName} (${count})`;
|
||||
try {
|
||||
const id = await mainApp['api/category-create']({
|
||||
const id = await send('api/category-create', {
|
||||
category: { ...params, name },
|
||||
});
|
||||
return { id, name };
|
||||
@@ -394,7 +393,7 @@ async function importCategories(
|
||||
groupId = createdGroup.id;
|
||||
entityIdMap.set(group.id, groupId);
|
||||
if (group.note) {
|
||||
void mainApp['notes-save']({
|
||||
void send('notes-save', {
|
||||
id: groupId,
|
||||
note: group.note,
|
||||
});
|
||||
@@ -435,7 +434,7 @@ async function importCategories(
|
||||
});
|
||||
entityIdMap.set(cat.id, createdCategory.id);
|
||||
if (cat.note) {
|
||||
void mainApp['notes-save']({
|
||||
void send('notes-save', {
|
||||
id: createdCategory.id,
|
||||
note: cat.note,
|
||||
});
|
||||
@@ -452,7 +451,7 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
|
||||
return Promise.all(
|
||||
data.payees.map(async payee => {
|
||||
if (!payee.deleted) {
|
||||
const id = await mainApp['api/payee-create']({
|
||||
const id = await send('api/payee-create', {
|
||||
payee: { name: payee.name },
|
||||
});
|
||||
entityIdMap.set(payee.id, id);
|
||||
@@ -499,7 +498,7 @@ async function importPayeeLocations(
|
||||
|
||||
try {
|
||||
// Create the payee location in Actual
|
||||
await mainApp.createPayeeLocation({
|
||||
await send('payee-location-create', {
|
||||
payeeId: actualPayeeId,
|
||||
latitude,
|
||||
longitude,
|
||||
@@ -558,7 +557,7 @@ async function importFlagsAsTags(
|
||||
|
||||
await Promise.all(
|
||||
[...tagsToCreate.entries()].map(async ([tag, color]) => {
|
||||
await mainApp['tags-create']({
|
||||
await send('tags-create', {
|
||||
tag,
|
||||
color,
|
||||
description: 'Imported from YNAB',
|
||||
@@ -572,8 +571,8 @@ async function importTransactions(
|
||||
entityIdMap: Map<string, string>,
|
||||
flagNameConflicts: Set<string>,
|
||||
) {
|
||||
const payees = await mainApp['api/payees-get']();
|
||||
const categories = await mainApp['api/categories-get']({
|
||||
const payees = await send('api/payees-get');
|
||||
const categories = await send('api/categories-get', {
|
||||
grouped: false,
|
||||
});
|
||||
const incomeCatId = findIdByName(categories, 'Income');
|
||||
@@ -838,7 +837,7 @@ async function importTransactions(
|
||||
})
|
||||
.filter(x => x);
|
||||
|
||||
await mainApp['api/transactions-add']({
|
||||
await send('api/transactions-add', {
|
||||
accountId: entityIdMap.get(accountId),
|
||||
transactions: toImport,
|
||||
learnCategories: true,
|
||||
@@ -862,7 +861,7 @@ async function importScheduledTransactions(
|
||||
return;
|
||||
}
|
||||
|
||||
const payees = await mainApp['api/payees-get']();
|
||||
const payees = await send('api/payees-get');
|
||||
const payeesByTransferAcct = payees
|
||||
.filter(payee => payee?.transfer_acct)
|
||||
.map(payee => [payee.transfer_acct, payee] as [string, Payee]);
|
||||
@@ -885,7 +884,7 @@ async function importScheduledTransactions(
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await mainApp['api/schedule-create']({
|
||||
return await send('api/schedule-create', {
|
||||
...params,
|
||||
name: params.name,
|
||||
});
|
||||
@@ -903,16 +902,19 @@ async function importScheduledTransactions(
|
||||
async function getRuleForSchedule(
|
||||
scheduleId: string,
|
||||
): Promise<RuleEntity | null> {
|
||||
const { data: ruleId } = (await aqlQuery(
|
||||
q('schedules').filter({ id: scheduleId }).calculate('rule').serialize(),
|
||||
)) as { data: string | null };
|
||||
const { data: ruleId } = (await send('api/query', {
|
||||
query: q('schedules')
|
||||
.filter({ id: scheduleId })
|
||||
.calculate('rule')
|
||||
.serialize(),
|
||||
})) as { data: string | null };
|
||||
if (!ruleId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: ruleData } = (await aqlQuery(
|
||||
q('rules').filter({ id: ruleId }).select('*').serialize(),
|
||||
)) as { data: Array<Record<string, unknown>> };
|
||||
const { data: ruleData } = (await send('api/query', {
|
||||
query: q('rules').filter({ id: ruleId }).select('*').serialize(),
|
||||
})) as { data: Array<Record<string, unknown>> };
|
||||
const ruleRow = ruleData?.[0];
|
||||
if (!ruleRow) {
|
||||
return null;
|
||||
@@ -971,7 +973,7 @@ async function importScheduledTransactions(
|
||||
value: scheduleNotes,
|
||||
});
|
||||
|
||||
await mainApp['api/rule-update']({
|
||||
await send('api/rule-update', {
|
||||
rule: buildRuleUpdate(rule, actions),
|
||||
});
|
||||
}
|
||||
@@ -1006,7 +1008,7 @@ async function importScheduledTransactions(
|
||||
value: categoryId,
|
||||
});
|
||||
|
||||
await mainApp['api/rule-update']({
|
||||
await send('api/rule-update', {
|
||||
rule: buildRuleUpdate(rule, actions),
|
||||
});
|
||||
}
|
||||
@@ -1085,7 +1087,7 @@ async function importScheduledTransactions(
|
||||
}
|
||||
});
|
||||
|
||||
await mainApp['api/rule-update']({
|
||||
await send('api/rule-update', {
|
||||
rule: buildRuleUpdate(rule, actions),
|
||||
});
|
||||
}
|
||||
@@ -1112,7 +1114,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
|
||||
'Credit Card Payments',
|
||||
);
|
||||
|
||||
await mainApp['api/batch-budget-start']();
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
for (const budget of budgets) {
|
||||
const month = monthUtils.monthFromDate(budget.month);
|
||||
@@ -1130,7 +1132,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mainApp['api/budget-set-amount']({
|
||||
await send('api/budget-set-amount', {
|
||||
month,
|
||||
categoryId: catId,
|
||||
amount,
|
||||
@@ -1139,7 +1141,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await mainApp['api/batch-budget-end']();
|
||||
await send('api/batch-budget-end');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
packages/loot-core/src/server/main-app.ts
Normal file
25
packages/loot-core/src/server/main-app.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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]>>
|
||||
>;
|
||||
}
|
||||
@@ -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 { mainApp } from './main';
|
||||
import { handlers } from './main';
|
||||
import {
|
||||
disableGlobalMutations,
|
||||
enableGlobalMutations,
|
||||
@@ -28,7 +28,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await mainApp['close-budget']();
|
||||
await runHandler(handlers['close-budget']);
|
||||
connection.resetEvents();
|
||||
enableGlobalMutations();
|
||||
global.currentMonth = null;
|
||||
@@ -72,7 +72,7 @@ describe('Budgets', () => {
|
||||
'SELECT * FROM messages_clock',
|
||||
);
|
||||
|
||||
const { error } = await mainApp['load-budget']({
|
||||
const { error } = await runHandler(handlers['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 mainApp['load-budget']({
|
||||
const { error } = await runHandler(handlers['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 mainApp['transaction-add']({
|
||||
await runHandler(handlers['transaction-add'], {
|
||||
id,
|
||||
account: 'one',
|
||||
amount: 5000,
|
||||
@@ -140,7 +140,7 @@ describe('Accounts', () => {
|
||||
);
|
||||
|
||||
let transaction = await db.getTransaction(id);
|
||||
await mainApp['transaction-update']({
|
||||
await runHandler(handlers['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 mainApp['transaction-delete'](transaction);
|
||||
await runHandler(handlers['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 mainApp['get-budget-bounds']();
|
||||
let bounds = await runHandler(handlers['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 mainApp['transaction-add']({
|
||||
await runHandler(handlers['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 mainApp['get-budget-bounds']();
|
||||
bounds = await runHandler(handlers['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 mainApp['category-create']({
|
||||
await runHandler(handlers['category-create'], {
|
||||
name: 'foo',
|
||||
groupId: 'group1',
|
||||
}),
|
||||
await mainApp['category-create']({
|
||||
await runHandler(handlers['category-create'], {
|
||||
name: 'bar',
|
||||
groupId: 'group1',
|
||||
}),
|
||||
await mainApp['category-create']({
|
||||
await runHandler(handlers['category-create'], {
|
||||
name: 'baz',
|
||||
groupId: 'group1',
|
||||
}),
|
||||
await mainApp['category-create']({
|
||||
await runHandler(handlers['category-create'], {
|
||||
name: 'biz',
|
||||
groupId: 'group1',
|
||||
}),
|
||||
@@ -259,14 +259,14 @@ describe('Budget', () => {
|
||||
};
|
||||
// Test insertions
|
||||
let changed = await captureChangedCells(() =>
|
||||
mainApp['transaction-add'](trans),
|
||||
runHandler(handlers['transaction-add'], trans),
|
||||
);
|
||||
expect(
|
||||
changed.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)),
|
||||
).toMatchSnapshot();
|
||||
// Test updates
|
||||
changed = await captureChangedCells(async () => {
|
||||
await mainApp['transaction-update']({
|
||||
await runHandler(handlers['transaction-update'], {
|
||||
...(await db.getTransaction(trans.id)),
|
||||
amount: 7000,
|
||||
});
|
||||
@@ -276,7 +276,7 @@ describe('Budget', () => {
|
||||
).toMatchSnapshot();
|
||||
// Test deletions
|
||||
changed = await captureChangedCells(async () => {
|
||||
await mainApp['transaction-delete']({ id: trans.id });
|
||||
await runHandler(handlers['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 mainApp['category-delete']({ id: 'foo' });
|
||||
await runHandler(handlers['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 mainApp['category-delete']({
|
||||
await runHandler(handlers['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(
|
||||
mainApp['category-delete']({
|
||||
runHandler(handlers['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 mainApp['category-delete']({
|
||||
await runHandler(handlers['category-delete'], {
|
||||
id: 'income1',
|
||||
transferId: 'income2',
|
||||
});
|
||||
|
||||
@@ -6,15 +6,12 @@ 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 { ApiHandlers } from '../types/api-handlers';
|
||||
import type { Handlers, ServerHandlers } from '../types/handlers';
|
||||
import type { Handlers } from '../types/handlers';
|
||||
|
||||
import { app as accountsApp } from './accounts/app';
|
||||
import { app as adminApp } from './admin/app';
|
||||
import { app as apiApp } from './api';
|
||||
import { createApp } from './app';
|
||||
import { installAPI } from './api';
|
||||
import { aqlQuery } from './aql';
|
||||
import { app as authApp } from './auth/app';
|
||||
import { app as budgetApp } from './budget/app';
|
||||
@@ -24,7 +21,8 @@ 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 { mutator } from './mutators';
|
||||
import { app } from './main-app';
|
||||
import { mutator, runHandler } from './mutators';
|
||||
import { app as notesApp } from './notes/app';
|
||||
import { app as payeesApp } from './payees/app';
|
||||
import { get } from './post';
|
||||
@@ -43,24 +41,38 @@ import { app as transactionsApp } from './transactions/app';
|
||||
import * as rules from './transactions/transaction-rules';
|
||||
import { redo, undo } from './undo';
|
||||
|
||||
async function makeFiltersFromConditions({
|
||||
// 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 ({
|
||||
conditions,
|
||||
applySpecialCases = undefined,
|
||||
applySpecialCases,
|
||||
}) {
|
||||
return rules.conditionsToAQL(conditions, { applySpecialCases });
|
||||
}
|
||||
};
|
||||
|
||||
async function query(query) {
|
||||
handlers['query'] = async function (query) {
|
||||
if (query['table'] == null) {
|
||||
throw new Error('query has no table, did you forgot to call `.serialize`?');
|
||||
}
|
||||
|
||||
return aqlQuery(query);
|
||||
}
|
||||
};
|
||||
|
||||
async function getServerVersion() {
|
||||
handlers['get-server-version'] = async function () {
|
||||
if (!getServer()) {
|
||||
return { error: 'no-server' as const };
|
||||
return { error: 'no-server' };
|
||||
}
|
||||
|
||||
let version;
|
||||
@@ -68,19 +80,19 @@ async function getServerVersion() {
|
||||
const res = await get(getServer().BASE_SERVER + '/info');
|
||||
|
||||
const info = JSON.parse(res);
|
||||
version = info.build.version as string;
|
||||
version = info.build.version;
|
||||
} catch {
|
||||
return { error: 'network-failure' as const };
|
||||
return { error: 'network-failure' };
|
||||
}
|
||||
|
||||
return { version };
|
||||
}
|
||||
};
|
||||
|
||||
async function getServerUrl() {
|
||||
handlers['get-server-url'] = async function () {
|
||||
return getServer() && getServer().BASE_SERVER;
|
||||
}
|
||||
};
|
||||
|
||||
async function setServerUrl({ url, validate = true }) {
|
||||
handlers['set-server-url'] = async function ({ url, validate = true }) {
|
||||
if (url == null) {
|
||||
await asyncStorage.removeItem('user-token');
|
||||
} else {
|
||||
@@ -88,7 +100,7 @@ async function setServerUrl({ url, validate = true }) {
|
||||
|
||||
if (validate) {
|
||||
// Validate the server is running
|
||||
const result = await mainApp['subscribe-needs-bootstrap']({
|
||||
const result = await runHandler(handlers['subscribe-needs-bootstrap'], {
|
||||
url,
|
||||
});
|
||||
if ('error' in result) {
|
||||
@@ -101,60 +113,20 @@ async function setServerUrl({ url, validate = true }) {
|
||||
await asyncStorage.setItem('did-bootstrap', true);
|
||||
setServer(url);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
async function appFocused() {
|
||||
handlers['app-focused'] = async function () {
|
||||
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>;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
handlers = installAPI(handlers) as Handlers;
|
||||
|
||||
const serverApp = createApp<ServerHandlers>();
|
||||
|
||||
serverApp.events.on('sync', event => {
|
||||
connection.send('sync-event', event);
|
||||
});
|
||||
|
||||
serverApp.combine(
|
||||
miscApp,
|
||||
// A hack for now until we clean up everything
|
||||
app.handlers = handlers;
|
||||
app.combine(
|
||||
authApp,
|
||||
schedulesApp,
|
||||
budgetApp,
|
||||
@@ -176,9 +148,6 @@ serverApp.combine(
|
||||
tagsApp,
|
||||
);
|
||||
|
||||
export const mainApp = createApp<Handlers>();
|
||||
mainApp.combine(apiApp, serverApp);
|
||||
|
||||
export function getDefaultDocumentDir() {
|
||||
return fs.join(process.env.ACTUAL_DOCUMENT_DIR, 'Actual');
|
||||
}
|
||||
@@ -240,7 +209,17 @@ export async function initApp(isDev, socketName) {
|
||||
}
|
||||
setServer(url);
|
||||
|
||||
connection.init(socketName, mainApp);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
type BaseInitConfig = {
|
||||
@@ -299,25 +278,25 @@ export async function init(config: InitConfig) {
|
||||
|
||||
if ('sessionToken' in config && config.sessionToken) {
|
||||
// Session token authentication
|
||||
await mainApp['subscribe-set-token']({
|
||||
await runHandler(handlers['subscribe-set-token'], {
|
||||
token: config.sessionToken,
|
||||
});
|
||||
// Validate the token
|
||||
const user = await mainApp['subscribe-get-user']();
|
||||
const user = await runHandler(handlers['subscribe-get-user'], undefined);
|
||||
if (!user || user.tokenExpired === true) {
|
||||
// Clear invalid token
|
||||
await mainApp['subscribe-set-token']({ token: '' });
|
||||
await runHandler(handlers['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 mainApp['subscribe-set-token']({ token: '' });
|
||||
await runHandler(handlers['subscribe-set-token'], { token: '' });
|
||||
throw new Error('Authentication failed: server offline or unreachable');
|
||||
}
|
||||
} else if ('password' in config && config.password) {
|
||||
const result = await mainApp['subscribe-sign-in']({
|
||||
const result = await runHandler(handlers['subscribe-sign-in'], {
|
||||
password: config.password,
|
||||
});
|
||||
if (result?.error) {
|
||||
@@ -329,7 +308,7 @@ export async function init(config: InitConfig) {
|
||||
// access to the server, we are doing things locally
|
||||
setServer(null);
|
||||
|
||||
mainApp.events.on('load-budget', () => {
|
||||
app.events.on('load-budget', () => {
|
||||
setSyncingMode('offline');
|
||||
});
|
||||
}
|
||||
@@ -342,14 +321,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>(
|
||||
send: async <K extends keyof Handlers, T extends Handlers[K]>(
|
||||
name: K,
|
||||
args?: Parameters<Handlers[K]>[0],
|
||||
): Promise<Awaited<ReturnType<Handlers[K]>>> => {
|
||||
const res = await mainApp.runHandler(name, args);
|
||||
return res as Awaited<ReturnType<Handlers[K]>>;
|
||||
args?: Parameters<T>[0],
|
||||
): Promise<Awaited<ReturnType<T>>> => {
|
||||
const res = await runHandler(app.handlers[name], args);
|
||||
return res;
|
||||
},
|
||||
on: (name, func) => mainApp.events.on(name, func),
|
||||
on: (name, func) => app.events.on(name, func),
|
||||
q,
|
||||
db,
|
||||
amountToInteger,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { captureBreadcrumb, captureException } from '../platform/exceptions';
|
||||
import { sequential } from '../shared/async';
|
||||
|
||||
type Handler<TArgs extends unknown[] = unknown[], TReturn = unknown> = (
|
||||
...args: TArgs
|
||||
) => TReturn;
|
||||
import type { HandlerFunctions, Handlers } from '../types/handlers';
|
||||
|
||||
const runningMethods = new Set();
|
||||
|
||||
@@ -14,9 +11,7 @@ let globalMutationsEnabled = false;
|
||||
|
||||
let _latestHandlerNames = [];
|
||||
|
||||
export function mutator<TArgs extends unknown[], TReturn>(
|
||||
handler: Handler<TArgs, TReturn>,
|
||||
): Handler<TArgs, TReturn> {
|
||||
export function mutator<T extends HandlerFunctions>(handler: T): T {
|
||||
mutatingMethods.set(handler, true);
|
||||
return handler;
|
||||
}
|
||||
@@ -43,11 +38,11 @@ function wait(time) {
|
||||
return new Promise(resolve => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
export async function runHandler<TArgs extends unknown[], TReturn>(
|
||||
handler: Handler<TArgs, Promise<TReturn>>,
|
||||
args?: TArgs[0],
|
||||
export async function runHandler<T extends Handlers[keyof Handlers]>(
|
||||
handler: T,
|
||||
args?: Parameters<T>[0],
|
||||
{ undoTag, name }: { undoTag?; name? } = {},
|
||||
): Promise<TReturn> {
|
||||
): Promise<ReturnType<T>> {
|
||||
// For debug reasons, track the latest handlers that have been
|
||||
// called
|
||||
_latestHandlerNames.push(name);
|
||||
@@ -55,16 +50,10 @@ export async function runHandler<TArgs extends unknown[], TReturn>(
|
||||
_latestHandlerNames = _latestHandlerNames.slice(-5);
|
||||
}
|
||||
|
||||
const invokeHandler = () =>
|
||||
handler(...((args !== undefined ? [args] : []) as TArgs));
|
||||
|
||||
if (mutatingMethods.has(handler)) {
|
||||
// If already inside a mutator, call directly to avoid deadlocking the
|
||||
// sequential queue.
|
||||
if (currentContext !== null) {
|
||||
return invokeHandler();
|
||||
}
|
||||
return runMutator(invokeHandler, { undoTag });
|
||||
return runMutator(() => handler(args), { undoTag }) as Promise<
|
||||
ReturnType<T>
|
||||
>;
|
||||
}
|
||||
|
||||
// When closing a file, it clears out all global state for the file. That
|
||||
@@ -75,12 +64,12 @@ export async function runHandler<TArgs extends unknown[], TReturn>(
|
||||
await flushRunningMethods();
|
||||
}
|
||||
|
||||
const promise = invokeHandler();
|
||||
const promise = handler(args);
|
||||
runningMethods.add(promise);
|
||||
void promise.then(() => {
|
||||
runningMethods.delete(promise);
|
||||
});
|
||||
return promise;
|
||||
return promise as Promise<ReturnType<T>>;
|
||||
}
|
||||
|
||||
// These are useful for tests. Only use them in tests.
|
||||
|
||||
@@ -14,42 +14,44 @@ import { batchMessages } from '../sync';
|
||||
import * as rules from '../transactions/transaction-rules';
|
||||
import { undoable } from '../undo';
|
||||
|
||||
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 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 const app = createApp<PayeeHandlers>({
|
||||
createPayee: mutator(undoable(createPayee)),
|
||||
batchChangePayees: mutator(undoable(batchChangePayees)),
|
||||
createPayeeLocation: mutator(createPayeeLocation),
|
||||
deletePayeeLocation: mutator(deletePayeeLocation),
|
||||
mergePayees: mutator(
|
||||
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(
|
||||
undoable(mergePayees, args => ({
|
||||
mergeIds: args.mergeIds,
|
||||
targetId: args.targetId,
|
||||
})),
|
||||
),
|
||||
getCommonPayees,
|
||||
getPayees,
|
||||
getOrphanedPayees,
|
||||
getPayeeRuleCounts,
|
||||
checkOrphanedPayees,
|
||||
getPayeeRules,
|
||||
getPayeeLocations,
|
||||
getNearbyPayees,
|
||||
});
|
||||
);
|
||||
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);
|
||||
|
||||
async function createPayee({ name }: { name: PayeeEntity['name'] }) {
|
||||
return db.insertPayee({ name });
|
||||
|
||||
@@ -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 { mainApp } from '../main';
|
||||
import { app } from '../main-app';
|
||||
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));
|
||||
mainApp.events.emit('sync', {
|
||||
app.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.
|
||||
mainApp.events.emit('sync', {
|
||||
app.events.emit('sync', {
|
||||
type: 'error',
|
||||
subtype: 'apply-failure',
|
||||
meta: e.meta,
|
||||
});
|
||||
} else {
|
||||
mainApp.events.emit('sync', { type: 'error', meta: e.meta });
|
||||
app.events.emit('sync', { type: 'error', meta: e.meta });
|
||||
}
|
||||
} else if (e instanceof Timestamp.ClockDriftError) {
|
||||
mainApp.events.emit('sync', {
|
||||
app.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 } }
|
||||
> {
|
||||
mainApp.events.emit('sync', { type: 'start' });
|
||||
app.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);
|
||||
|
||||
mainApp.events.emit('sync', {
|
||||
app.events.emit('sync', {
|
||||
type: 'error',
|
||||
subtype: 'out-of-sync',
|
||||
meta: e.meta,
|
||||
});
|
||||
} else if (e.reason === 'invalid-schema') {
|
||||
mainApp.events.emit('sync', {
|
||||
app.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'
|
||||
) {
|
||||
mainApp.events.emit('sync', {
|
||||
app.events.emit('sync', {
|
||||
type: 'error',
|
||||
subtype: e.reason,
|
||||
meta: e.meta,
|
||||
});
|
||||
} else if (e.reason === 'clock-drift') {
|
||||
mainApp.events.emit('sync', {
|
||||
app.events.emit('sync', {
|
||||
type: 'error',
|
||||
subtype: 'clock-drift',
|
||||
meta: e.meta,
|
||||
});
|
||||
} else {
|
||||
mainApp.events.emit('sync', { type: 'error', meta: e.meta });
|
||||
app.events.emit('sync', { type: 'error', meta: e.meta });
|
||||
}
|
||||
} else if (e instanceof PostError) {
|
||||
logger.log(e);
|
||||
if (e.reason === 'unauthorized') {
|
||||
mainApp.events.emit('sync', { type: 'unauthorized' });
|
||||
app.events.emit('sync', { type: 'unauthorized' });
|
||||
|
||||
// Set the user into read-only mode
|
||||
void asyncStorage.setItem('readOnly', 'true');
|
||||
} else if (e.reason === 'network-failure') {
|
||||
mainApp.events.emit('sync', { type: 'error', subtype: 'network' });
|
||||
app.events.emit('sync', { type: 'error', subtype: 'network' });
|
||||
} else {
|
||||
mainApp.events.emit('sync', { type: 'error', subtype: e.reason });
|
||||
app.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
|
||||
mainApp.events.emit('sync', { type: 'error' });
|
||||
app.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);
|
||||
|
||||
mainApp.events.emit('sync', {
|
||||
app.events.emit('sync', {
|
||||
type: 'success',
|
||||
tables,
|
||||
syncDisabled: checkSyncingMode('disabled'),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import * as fs from '../../platform/server/fs';
|
||||
import { mainApp } from '../main';
|
||||
import { handlers } from '../main';
|
||||
|
||||
export async function uniqueBudgetName(
|
||||
initialName: string = 'My Finances',
|
||||
): Promise<string> {
|
||||
const budgets = await mainApp['get-budgets']();
|
||||
const budgets = await handlers['get-budgets']();
|
||||
let idx = 1;
|
||||
|
||||
// If there is a conflict, keep appending an index until there is no
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { App } from '../server/app';
|
||||
@@ -6,9 +6,8 @@ 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 { PayeeHandlers } from '../server/payees/app';
|
||||
import type { PayeesHandlers } 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';
|
||||
@@ -20,8 +19,10 @@ 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 ServerHandlers = MiscHandlers &
|
||||
export type Handlers = {} & ServerHandlers &
|
||||
ApiHandlers &
|
||||
BudgetHandlers &
|
||||
DashboardHandlers &
|
||||
FiltersHandlers &
|
||||
@@ -34,7 +35,7 @@ export type ServerHandlers = MiscHandlers &
|
||||
AdminHandlers &
|
||||
ToolsHandlers &
|
||||
AccountHandlers &
|
||||
PayeeHandlers &
|
||||
PayeesHandlers &
|
||||
SpreadsheetHandlers &
|
||||
SyncHandlers &
|
||||
BudgetFileHandlers &
|
||||
@@ -42,6 +43,4 @@ export type ServerHandlers = MiscHandlers &
|
||||
TagsHandlers &
|
||||
AuthHandlers;
|
||||
|
||||
export type Handlers = {} & ServerHandlers & ApiHandlers;
|
||||
|
||||
export type HandlerFunctions = Handlers[keyof Handlers];
|
||||
|
||||
27
packages/loot-core/src/types/server-handlers.ts
Normal file
27
packages/loot-core/src/types/server-handlers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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>;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
cli: improved aql support
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Refactor client-server communication to use typed server proxy methods for improved clarity and safety.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.
|
||||
Reference in New Issue
Block a user