Compare commits

...

4 Commits

Author SHA1 Message Date
autofix-ci[bot]
94e91eea6c [autofix.ci] apply automated fixes 2026-03-21 21:13:53 +00:00
Matiss Janis Aboltins
b04097c6d1 [AI] Consolidate loot-core connection: default web path, electron split, drop .browser 2026-03-21 21:08:55 +00:00
Matiss Janis Aboltins
23adf06cb0 Add post-merge hook to auto-install dependencies (#7248)
* [AI] Add post-merge hook to auto-install when yarn.lock changes

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

https://claude.ai/code/session_01JHoMhGANLTc1q67s1dUHrt

* Add release notes for PR #7248

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 22:59:05 +00:00
Matiss Janis Aboltins
a8a2d23e63 [AI] Improve CLI query command with new flags, subcommands, and docs (#7240)
* [AI] Improve CLI query command with new flags, subcommands, and docs

Add --last, --count, --where, --offset, --group-by flags and field:desc
order-by syntax to make the query command more usable for both humans
and AI agents. Add query tables/fields subcommands for schema discovery.
Expand CLI and ActualQL documentation with comprehensive examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix parseOrderBy empty field validation and misleading aggregate docs

Validate that order-by field names are non-empty (e.g. ":desc" now throws
a clear error). Move AVAILABLE_TABLES before first use. Update group-by
examples in CLI help text and docs to use --file with proper aggregate
JSON instead of misleading --select with plain field names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-20 19:01:20 +00:00
16 changed files with 1047 additions and 346 deletions

7
.husky/post-merge Executable file
View File

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

View File

@@ -335,7 +335,7 @@
],
"patterns": [
{
"group": ["**/*.api", "**/*.web", "**/*.electron"],
"group": ["**/*.api", "**/*.electron"],
"message": "Don't directly reference imports from other platforms"
},
{

View File

@@ -331,7 +331,7 @@ Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
- Don't directly reference platform-specific imports (`.api`, `.electron`)
- Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports
@@ -501,7 +501,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
3. Verify platform-specific imports (`.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures

View File

@@ -0,0 +1,346 @@
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"',
);
});
});
});

View File

@@ -10,6 +10,115 @@ 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,
@@ -27,34 +136,125 @@ 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>) {
if (!cmdOpts.table) {
throw new Error('--table is required (or use --file)');
}
let queryObj = api.q(cmdOpts.table);
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
if (cmdOpts.select) {
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');
}
}
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) {
queryObj = queryObj.select(cmdOpts.select.split(','));
} else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
}
if (cmdOpts.filter) {
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
}
if (cmdOpts.orderBy) {
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
const orderByStr =
cmdOpts.orderBy ??
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
if (orderByStr) {
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
}
if (cmdOpts.limit) {
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
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(','));
}
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')
@@ -65,16 +265,34 @@ export function registerQueryCommand(program: Command) {
.description('Execute an AQL query')
.option(
'--table <table>',
'Table to query (transactions, accounts, categories, payees)',
'Table to query (use "actual query tables" to list available tables)',
)
.option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter expression as JSON')
.option('--order-by <fields>', 'Comma-separated fields to order by')
.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('--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 () => {
@@ -87,7 +305,40 @@ export function registerQueryCommand(program: Command) {
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
printOutput(result, opts.format);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
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);
});
}

View File

@@ -116,7 +116,7 @@ export default defineConfig(async ({ mode }) => {
process.env.REACT_APP_BRANCH = process.env.BRANCH;
}
let resolveExtensions = [
const resolveExtensions = [
'.mjs',
'.js',
'.mts',
@@ -126,16 +126,6 @@ export default defineConfig(async ({ mode }) => {
'.json',
];
if (env.IS_GENERIC_BROWSER) {
resolveExtensions = [
'.browser.js',
'.browser.jsx',
'.browser.ts',
'.browser.tsx',
...resolveExtensions,
];
}
const browserOpen = env.BROWSER_OPEN ? `//${env.BROWSER_OPEN}` : true;
return {

View File

@@ -101,3 +101,30 @@ 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
```

View File

@@ -274,16 +274,80 @@ actual schedules delete <id>
### Query (ActualQL)
Run queries using [ActualQL](./actual-ql/index.md):
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
```bash
# Run a query (inline)
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
# Show last 5 transactions (convenience shortcut)
actual query run --last 5
# Run a query (from file)
# 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
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

View File

@@ -32,10 +32,6 @@
"./client/store": "./src/client/store/index.ts",
"./client/store/mock": "./src/client/store/mock.ts",
"./client/users/*": "./src/client/users/*.ts",
"./client/platform": {
"node": "./src/client/platform.electron.ts",
"default": "./src/client/platform.web.ts"
},
"./client/queries": "./src/client/queries.ts",
"./client/query-helpers": "./src/client/query-helpers.ts",
"./client/query-hooks": "./src/client/query-hooks.ts",
@@ -46,8 +42,8 @@
"./client/undo": "./src/client/undo.ts",
"./mocks": "./src/mocks/index.ts",
"./platform/client/connection": {
"electron": "./src/platform/client/connection/index.ts",
"default": "./src/platform/client/connection/index.browser.ts"
"electron": "./src/platform/client/connection/index.electron.ts",
"default": "./src/platform/client/connection/index.ts"
},
"./platform/client/undo": "./src/platform/client/undo/index.ts",
"./platform/exceptions": "./src/platform/exceptions/index.ts",

View File

@@ -0,0 +1,2 @@
// oxlint-disable-next-line no-restricted-imports
export * from './index.electron';

View File

@@ -1,213 +0,0 @@
// @ts-strict-ignore
import { t } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { captureBreadcrumb, captureException } from '../../exceptions';
import * as undo from '../undo';
import type * as T from './index-types';
const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let globalWorker = null;
class ReconstructedError extends Error {
url: string;
line: string;
column: string;
constructor(message, stack, url, line, column) {
super(message);
this.name = this.constructor.name;
this.message = message;
Object.defineProperty(this, 'stack', {
get: function () {
return 'extended ' + this._stack;
},
set: function (value) {
this._stack = value;
},
});
this.stack = stack;
this.url = url;
this.line = line;
this.column = column;
}
}
function handleMessage(msg) {
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
const { id, result, mutated, undoTag } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
// Ignore internal messages that start with __
if (!msg.type.startsWith('__')) {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
}
}
// 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.
function connectWorker(worker, onOpen, onError) {
globalWorker = worker;
worker.onmessage = event => {
const msg = event.data;
// The worker implementation implements its own concept of a
// 'connect' event because the worker is immediately
// available, but we don't know when the backend is actually
// ready to handle messages.
if (msg.type === 'connect') {
// Send any messages that were queued while closed
if (messageQueue?.length > 0) {
messageQueue.forEach(msg => worker.postMessage(msg));
messageQueue = null;
}
// signal to the backend that we're connected to it
globalWorker.postMessage({
name: 'client-connected-to-backend',
});
onOpen();
} else if (msg.type === 'app-init-failure') {
globalWorker.postMessage({
name: '__app-init-failure-acknowledged',
});
onError(msg);
} else if (msg.type === 'capture-exception') {
captureException(
msg.stack
? new ReconstructedError(
msg.message,
msg.stack,
msg.url,
msg.line,
msg.column,
)
: msg.exc,
);
if (msg.message && msg.message.includes('indexeddb-quota-error')) {
alert(
t(
'We hit a limit on the local storage available. Edits may not be saved. Please get in touch https://actualbudget.org/contact/ so we can help debug this.',
),
);
}
} else if (msg.type === 'capture-breadcrumb') {
captureBreadcrumb(msg.data);
} else {
handleMessage(msg);
}
};
// In browsers that don't support wasm in workers well (Safari),
// we run the server on the main process for now. This might not
// actually be a worker, but instead a message port which we
// need to start.
if (worker instanceof MessagePort) {
worker.start();
}
}
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 (
...params: Parameters<T.Send>
): ReturnType<T.Send> {
const [name, args, { catchErrors = false } = {}] = params;
return new Promise((resolve, reject) => {
const id = uuidv4();
replyHandlers.set(id, { resolve, reject });
const message = {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
};
if (messageQueue) {
messageQueue.push(message);
} else {
globalWorker.postMessage(message);
}
});
};
export const sendCatch: T.SendCatch = function (name, args) {
return send(name, args, { catchErrors: true });
};
export const listen: T.Listen = function (name, cb) {
if (!listeners.get(name)) {
listeners.set(name, []);
}
listeners.get(name).push(cb);
return () => {
const arr = listeners.get(name);
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
};
};
export const unlisten: T.Unlisten = function (name) {
listeners.set(name, []);
};
export const initServer: T.InitServer = async function () {
// initServer is used in tests to mock the server
};
export const serverPush: T.ServerPush = async function () {
// serverPush is used in tests to mock the server
};
export const clearServer: T.ClearServer = async function () {
// clearServer is used in tests to mock the server
};

View File

@@ -0,0 +1,155 @@
// @ts-strict-ignore
import { v4 as uuidv4 } from 'uuid';
import * as undo from '../undo';
import type * as T from './index-types';
const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let socketClient = null;
function connectSocket(onOpen) {
global.Actual.ipcConnect(function (client) {
client.on('message', data => {
const msg = data;
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
let { result } = msg;
const { id, mutated, undoTag } = msg;
// Check if the result is a serialized buffer, and if so
// convert it to a Uint8Array. This is only needed when working
// with node; the web version connection layer automatically
// supports buffers
if (result && result.type === 'Buffer' && Array.isArray(result.data)) {
result = new Uint8Array(result.data);
}
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
});
socketClient = client;
// Send any messages that were queued while closed
if (messageQueue.length > 0) {
messageQueue.forEach(msg => client.emit('message', msg));
messageQueue = [];
}
onOpen();
});
}
export const init: T.Init = async function () {
return new Promise(connectSocket);
};
export const send: T.Send = function (
...params: Parameters<T.Send>
): ReturnType<T.Send> {
const [name, args, { catchErrors = false } = {}] = params;
return new Promise((resolve, reject) => {
const id = uuidv4();
replyHandlers.set(id, { resolve, reject });
if (socketClient) {
socketClient.emit('message', {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors: !!catchErrors,
});
} else {
messageQueue.push({
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
});
}
});
};
export const sendCatch: T.SendCatch = function (name, args) {
return send(name, args, { catchErrors: true });
};
export const listen: T.Listen = function (name, cb) {
if (!listeners.get(name)) {
listeners.set(name, []);
}
listeners.get(name).push(cb);
return () => {
const arr = listeners.get(name);
if (arr) {
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
}
};
};
export const unlisten: T.Unlisten = function (name) {
listeners.set(name, []);
};
async function closeSocket(onClose) {
socketClient.onclose = () => {
socketClient = null;
onClose();
};
await socketClient.close();
}
export const clearServer: T.ClearServer = async function () {
if (socketClient != null) {
return new Promise(closeSocket);
}
};
export const initServer: T.InitServer = async function () {
// initServer is used in tests to mock the server
};
export const serverPush: T.ServerPush = async function () {
// serverPush is used in tests to mock the server
};

View File

@@ -1,6 +1,8 @@
// @ts-strict-ignore
import { t } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { captureBreadcrumb, captureException } from '../../exceptions';
import * as undo from '../undo';
import type * as T from './index-types';
@@ -8,76 +10,150 @@ import type * as T from './index-types';
const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let socketClient = null;
function connectSocket(onOpen) {
global.Actual.ipcConnect(function (client) {
client.on('message', data => {
const msg = data;
let globalWorker = null;
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
let { result } = msg;
const { id, mutated, undoTag } = msg;
class ReconstructedError extends Error {
url: string;
line: string;
column: string;
// Check if the result is a serialized buffer, and if so
// convert it to a Uint8Array. This is only needed when working
// with node; the web version connection layer automatically
// supports buffers
if (result && result.type === 'Buffer' && Array.isArray(result.data)) {
result = new Uint8Array(result.data);
}
constructor(message, stack, url, line, column) {
super(message);
this.name = this.constructor.name;
this.message = message;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
Object.defineProperty(this, 'stack', {
get: function () {
return 'extended ' + this._stack;
},
set: function (value) {
this._stack = value;
},
});
socketClient = client;
this.stack = stack;
this.url = url;
this.line = line;
this.column = column;
}
}
// Send any messages that were queued while closed
if (messageQueue.length > 0) {
messageQueue.forEach(msg => client.emit('message', msg));
messageQueue = [];
function handleMessage(msg) {
if (msg.type === 'error') {
// An error happened while handling a message so cleanup the
// current reply handler and reject the promise. The error will
// be propagated to the caller through this promise rejection.
const { id, error } = msg;
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
handler.reject(error);
}
} else if (msg.type === 'reply') {
const { id, result, mutated, undoTag } = msg;
onOpen();
});
const handler = replyHandlers.get(id);
if (handler) {
replyHandlers.delete(id);
if (!mutated) {
undo.gc(undoTag);
}
handler.resolve(result);
}
} else if (msg.type === 'push') {
const { name, args } = msg;
const listens = listeners.get(name);
if (listens) {
for (let i = 0; i < listens.length; i++) {
const stop = listens[i](args);
if (stop === true) {
break;
}
}
}
} else {
// Ignore internal messages that start with __
if (!msg.type.startsWith('__')) {
throw new Error('Unknown message type: ' + JSON.stringify(msg));
}
}
}
// 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.
function connectWorker(worker, onOpen, onError) {
globalWorker = worker;
worker.onmessage = event => {
const msg = event.data;
// The worker implementation implements its own concept of a
// 'connect' event because the worker is immediately
// available, but we don't know when the backend is actually
// ready to handle messages.
if (msg.type === 'connect') {
// Send any messages that were queued while closed
if (messageQueue?.length > 0) {
messageQueue.forEach(msg => worker.postMessage(msg));
messageQueue = null;
}
// signal to the backend that we're connected to it
globalWorker.postMessage({
name: 'client-connected-to-backend',
});
onOpen();
} else if (msg.type === 'app-init-failure') {
globalWorker.postMessage({
name: '__app-init-failure-acknowledged',
});
onError(msg);
} else if (msg.type === 'capture-exception') {
captureException(
msg.stack
? new ReconstructedError(
msg.message,
msg.stack,
msg.url,
msg.line,
msg.column,
)
: msg.exc,
);
if (msg.message && msg.message.includes('indexeddb-quota-error')) {
alert(
t(
'We hit a limit on the local storage available. Edits may not be saved. Please get in touch https://actualbudget.org/contact/ so we can help debug this.',
),
);
}
} else if (msg.type === 'capture-breadcrumb') {
captureBreadcrumb(msg.data);
} else {
handleMessage(msg);
}
};
// In browsers that don't support wasm in workers well (Safari),
// we run the server on the main process for now. This might not
// actually be a worker, but instead a message port which we
// need to start.
if (worker instanceof MessagePort) {
worker.start();
}
}
export const init: T.Init = async function () {
return new Promise(connectSocket);
const worker = await global.Actual.getServerSocket();
return new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
);
};
export const send: T.Send = function (
@@ -86,24 +162,19 @@ export const send: T.Send = function (
const [name, args, { catchErrors = false } = {}] = params;
return new Promise((resolve, reject) => {
const id = uuidv4();
replyHandlers.set(id, { resolve, reject });
if (socketClient) {
socketClient.emit('message', {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors: !!catchErrors,
});
replyHandlers.set(id, { resolve, reject });
const message = {
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
};
if (messageQueue) {
messageQueue.push(message);
} else {
messageQueue.push({
id,
name,
args,
undoTag: undo.snapshot(),
catchErrors,
});
globalWorker.postMessage(message);
}
});
};
@@ -120,12 +191,10 @@ export const listen: T.Listen = function (name, cb) {
return () => {
const arr = listeners.get(name);
if (arr) {
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
}
listeners.set(
name,
arr.filter(cb_ => cb_ !== cb),
);
};
};
@@ -133,23 +202,12 @@ export const unlisten: T.Unlisten = function (name) {
listeners.set(name, []);
};
async function closeSocket(onClose) {
socketClient.onclose = () => {
socketClient = null;
onClose();
};
await socketClient.close();
}
export const clearServer: T.ClearServer = async function () {
if (socketClient != null) {
return new Promise(closeSocket);
}
};
export const initServer: T.InitServer = async function () {
// initServer is used in tests to mock the server
};
export const serverPush: T.ServerPush = async function () {
// serverPush is used in tests to mock the server
};
export const clearServer: T.ClearServer = async function () {
// clearServer is used in tests to mock the server
};

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
cli: improved aql support

View File

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

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Remove special "\*.browser.ts" file extension