actual-mcp-server 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +663 -0
  3. package/bin/actual-mcp-server.js +3 -0
  4. package/dist/generated/actual-client/types.js +5 -0
  5. package/dist/package.json +88 -0
  6. package/dist/src/actualConnection.js +157 -0
  7. package/dist/src/actualToolsManager.js +211 -0
  8. package/dist/src/auth/budget-acl.js +143 -0
  9. package/dist/src/auth/setup.js +58 -0
  10. package/dist/src/config.js +41 -0
  11. package/dist/src/index.js +313 -0
  12. package/dist/src/lib/ActualConnectionPool.js +343 -0
  13. package/dist/src/lib/ActualMCPConnection.js +125 -0
  14. package/dist/src/lib/actual-adapter.js +1228 -0
  15. package/dist/src/lib/actual-schema.js +222 -0
  16. package/dist/src/lib/budget-registry.js +64 -0
  17. package/dist/src/lib/constants.js +121 -0
  18. package/dist/src/lib/errors.js +19 -0
  19. package/dist/src/lib/loggerFactory.js +72 -0
  20. package/dist/src/lib/node-polyfills.js +20 -0
  21. package/dist/src/lib/query-validator.js +221 -0
  22. package/dist/src/lib/retry.js +26 -0
  23. package/dist/src/lib/schemas/common.js +203 -0
  24. package/dist/src/lib/toolFactory.js +109 -0
  25. package/dist/src/logger.js +127 -0
  26. package/dist/src/observability.js +58 -0
  27. package/dist/src/prompts/showLargeTransactions.js +6 -0
  28. package/dist/src/resources/accountsSummary.js +13 -0
  29. package/dist/src/server/httpServer.js +540 -0
  30. package/dist/src/server/httpServer_testing.js +401 -0
  31. package/dist/src/server/stdioServer.js +52 -0
  32. package/dist/src/server/streamable-http.js +148 -0
  33. package/dist/src/tests/actualToolsTests.js +70 -0
  34. package/dist/src/tests/observability.smoke.test.js +18 -0
  35. package/dist/src/tests/testMcpClient.js +170 -0
  36. package/dist/src/tests_adapter_runner.js +86 -0
  37. package/dist/src/tools/accounts_close.js +16 -0
  38. package/dist/src/tools/accounts_create.js +27 -0
  39. package/dist/src/tools/accounts_delete.js +16 -0
  40. package/dist/src/tools/accounts_get_balance.js +40 -0
  41. package/dist/src/tools/accounts_list.js +16 -0
  42. package/dist/src/tools/accounts_reopen.js +16 -0
  43. package/dist/src/tools/accounts_update.js +52 -0
  44. package/dist/src/tools/bank_sync.js +22 -0
  45. package/dist/src/tools/budget_updates_batch.js +77 -0
  46. package/dist/src/tools/budgets_getMonth.js +14 -0
  47. package/dist/src/tools/budgets_getMonths.js +14 -0
  48. package/dist/src/tools/budgets_get_all.js +13 -0
  49. package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
  50. package/dist/src/tools/budgets_list_available.js +20 -0
  51. package/dist/src/tools/budgets_resetHold.js +16 -0
  52. package/dist/src/tools/budgets_setAmount.js +26 -0
  53. package/dist/src/tools/budgets_setCarryover.js +18 -0
  54. package/dist/src/tools/budgets_switch.js +27 -0
  55. package/dist/src/tools/budgets_transfer.js +64 -0
  56. package/dist/src/tools/categories_create.js +65 -0
  57. package/dist/src/tools/categories_delete.js +16 -0
  58. package/dist/src/tools/categories_get.js +14 -0
  59. package/dist/src/tools/categories_update.js +22 -0
  60. package/dist/src/tools/category_groups_create.js +18 -0
  61. package/dist/src/tools/category_groups_delete.js +26 -0
  62. package/dist/src/tools/category_groups_get.js +13 -0
  63. package/dist/src/tools/category_groups_update.js +21 -0
  64. package/dist/src/tools/get_id_by_name.js +36 -0
  65. package/dist/src/tools/index.js +63 -0
  66. package/dist/src/tools/payee_rules_get.js +27 -0
  67. package/dist/src/tools/payees_create.js +25 -0
  68. package/dist/src/tools/payees_delete.js +16 -0
  69. package/dist/src/tools/payees_get.js +14 -0
  70. package/dist/src/tools/payees_merge.js +17 -0
  71. package/dist/src/tools/payees_update.js +59 -0
  72. package/dist/src/tools/query_run.js +78 -0
  73. package/dist/src/tools/rules_create.js +129 -0
  74. package/dist/src/tools/rules_create_or_update.js +191 -0
  75. package/dist/src/tools/rules_delete.js +26 -0
  76. package/dist/src/tools/rules_get.js +13 -0
  77. package/dist/src/tools/rules_update.js +120 -0
  78. package/dist/src/tools/schedules_create.js +54 -0
  79. package/dist/src/tools/schedules_delete.js +41 -0
  80. package/dist/src/tools/schedules_get.js +13 -0
  81. package/dist/src/tools/schedules_update.js +40 -0
  82. package/dist/src/tools/server_get_version.js +22 -0
  83. package/dist/src/tools/server_info.js +86 -0
  84. package/dist/src/tools/session_close.js +100 -0
  85. package/dist/src/tools/session_list.js +24 -0
  86. package/dist/src/tools/transactions_create.js +50 -0
  87. package/dist/src/tools/transactions_delete.js +20 -0
  88. package/dist/src/tools/transactions_filter.js +73 -0
  89. package/dist/src/tools/transactions_get.js +23 -0
  90. package/dist/src/tools/transactions_import.js +21 -0
  91. package/dist/src/tools/transactions_search_by_amount.js +126 -0
  92. package/dist/src/tools/transactions_search_by_category.js +137 -0
  93. package/dist/src/tools/transactions_search_by_month.js +142 -0
  94. package/dist/src/tools/transactions_search_by_payee.js +142 -0
  95. package/dist/src/tools/transactions_summary_by_category.js +80 -0
  96. package/dist/src/tools/transactions_summary_by_payee.js +72 -0
  97. package/dist/src/tools/transactions_uncategorized.js +66 -0
  98. package/dist/src/tools/transactions_update.js +34 -0
  99. package/dist/src/tools/transactions_update_batch.js +60 -0
  100. package/dist/src/utils.js +63 -0
  101. package/package.json +88 -0
@@ -0,0 +1,1228 @@
1
+ // Must be the very first import — sets globalThis.navigator before @actual-app/api is evaluated.
2
+ // Required by @actual-app/api v26.3.0+ which uses navigator.platform at module load time.
3
+ import './node-polyfills.js';
4
+ import api from '@actual-app/api';
5
+ // @actual-app/api is a CJS package (no "type" field). In NodeNext/ESM context TypeScript
6
+ // cannot expose its named exports via static import syntax. At runtime the default import
7
+ // IS module.exports, so all methods are accessible as properties.
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule,
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ } = api;
12
+ import { EventEmitter } from 'events';
13
+ import observability from '../observability.js';
14
+ import retry from './retry.js';
15
+ import logger from '../logger.js';
16
+ import config from '../config.js';
17
+ import { parseBudgetRegistry } from './budget-registry.js';
18
+ /**
19
+ * Budget registry — all budgets configured via ACTUAL_* and BUDGET_n_* env vars.
20
+ * Built once at startup; used by every withActualApi call.
21
+ */
22
+ const budgetRegistry = parseBudgetRegistry(process.env, {
23
+ serverUrl: config.ACTUAL_SERVER_URL,
24
+ password: config.ACTUAL_PASSWORD,
25
+ syncId: config.ACTUAL_BUDGET_SYNC_ID,
26
+ encryptionPassword: config.ACTUAL_BUDGET_PASSWORD,
27
+ });
28
+ logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
29
+ [...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
30
+ /**
31
+ * Key (lowercased budget name) of the currently active budget.
32
+ * Null = use the first entry in the registry (the default budget).
33
+ */
34
+ let activeBudgetKey = null;
35
+ function getActiveBudgetConfig() {
36
+ if (activeBudgetKey) {
37
+ const found = budgetRegistry.get(activeBudgetKey);
38
+ if (found)
39
+ return found;
40
+ }
41
+ return [...budgetRegistry.values()][0];
42
+ }
43
+ /**
44
+ * Global API session mutex.
45
+ * @actual-app/api is a singleton with a single SQLite connection — concurrent
46
+ * init/shutdown pairs corrupt the session. All callers (reads via withActualApi,
47
+ * writes via processWriteQueue) must acquire this lock before touching the API.
48
+ */
49
+ let _apiSessionLock = Promise.resolve();
50
+ function withApiLock(fn) {
51
+ let release;
52
+ const prevLock = _apiSessionLock;
53
+ _apiSessionLock = new Promise(resolve => { release = resolve; });
54
+ return prevLock.then(() => fn()).finally(() => release());
55
+ }
56
+ /**
57
+ * Helper to init and shutdown Actual API around each operation
58
+ * This is CRITICAL for data persistence - shutdown() must be called after every operation
59
+ * Based on the pattern from https://github.com/s-stefanov/actual-mcp
60
+ */
61
+ async function withActualApi(operation) {
62
+ return withApiLock(async () => {
63
+ try {
64
+ await initActualApiForOperation();
65
+ return await operation();
66
+ }
67
+ finally {
68
+ await shutdownActualApi();
69
+ }
70
+ });
71
+ }
72
+ /**
73
+ * Initialize Actual API - based on s-stefanov/actual-mcp pattern
74
+ * This calls api.init() and api.downloadBudget() for each operation
75
+ */
76
+ async function initActualApiForOperation() {
77
+ try {
78
+ const budget = getActiveBudgetConfig();
79
+ const DATA_DIR = config.MCP_BRIDGE_DATA_DIR;
80
+ logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
81
+ await api.init({
82
+ dataDir: DATA_DIR,
83
+ serverURL: budget.serverUrl,
84
+ password: budget.password || '',
85
+ });
86
+ logger.debug('[ADAPTER] Downloading budget');
87
+ if (budget.encryptionPassword) {
88
+ const apiWithOptions = api;
89
+ await apiWithOptions.downloadBudget(budget.syncId, { password: budget.encryptionPassword });
90
+ }
91
+ else {
92
+ await api.downloadBudget(budget.syncId);
93
+ }
94
+ logger.debug('[ADAPTER] Actual API initialized for operation');
95
+ }
96
+ catch (err) {
97
+ logger.error('[ADAPTER] Error initializing Actual API:', err);
98
+ throw err;
99
+ }
100
+ }
101
+ async function shutdownActualApi() {
102
+ try {
103
+ const maybeApi = api;
104
+ if (typeof maybeApi.shutdown === 'function') {
105
+ await maybeApi.shutdown();
106
+ logger.debug('[ADAPTER] Actual API shutdown complete');
107
+ }
108
+ }
109
+ catch (err) {
110
+ logger.error('[ADAPTER] Error during Actual API shutdown:', err);
111
+ }
112
+ }
113
+ import { BANK_SYNC_SETTLE_MS, DEFAULT_CONCURRENCY_LIMIT, WRITE_SESSION_DELAY_MS } from './constants.js';
114
+ /**
115
+ * Very small concurrency limiter for adapter calls. This prevents bursts from
116
+ * overloading the actual server. It's intentionally tiny and in-memory; replace
117
+ * with Bottleneck or p-queue for production.
118
+ */
119
+ let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
120
+ let running = 0;
121
+ const queue = [];
122
+ let writeQueue = [];
123
+ let isProcessingWrites = false;
124
+ let writeSessionTimeout = null;
125
+ async function processWriteQueue() {
126
+ // Atomically check and set processing flag to prevent race conditions
127
+ if (isProcessingWrites || writeQueue.length === 0)
128
+ return;
129
+ isProcessingWrites = true;
130
+ // Clear the timeout since we're processing now
131
+ if (writeSessionTimeout) {
132
+ clearTimeout(writeSessionTimeout);
133
+ writeSessionTimeout = null;
134
+ }
135
+ const batch = writeQueue.splice(0, writeQueue.length); // Take all current items
136
+ logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations`);
137
+ try {
138
+ await withApiLock(async () => {
139
+ try {
140
+ // Initialize API once for all queued writes
141
+ await initActualApiForOperation();
142
+ // Process all queued writes in the same session
143
+ // Each operation handles its own success/failure
144
+ await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
145
+ try {
146
+ const result = await operation();
147
+ resolve(result);
148
+ }
149
+ catch (error) {
150
+ logger.error('[WRITE QUEUE] Operation failed:', error);
151
+ reject(error);
152
+ }
153
+ }));
154
+ // Explicitly sync changes to server before shutdown
155
+ // This ensures all write operations are persisted
156
+ logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
157
+ try {
158
+ await api.sync();
159
+ logger.debug(`[WRITE QUEUE] Sync completed`);
160
+ }
161
+ catch (syncError) {
162
+ logger.error('[WRITE QUEUE] Sync failed:', syncError);
163
+ // Don't throw - we still want to shutdown cleanly
164
+ // Individual operation errors were already reported to callers
165
+ }
166
+ // Shutdown after all writes complete and sync
167
+ await shutdownActualApi();
168
+ logger.debug(`[WRITE QUEUE] Batch completed successfully`);
169
+ }
170
+ catch (error) {
171
+ logger.error('[WRITE QUEUE] Fatal error in write queue:', error);
172
+ // Reject any operations that weren't processed
173
+ batch.forEach(({ reject }) => {
174
+ try {
175
+ reject(error);
176
+ }
177
+ catch (e) {
178
+ logger.error('[WRITE QUEUE] Error rejecting operation:', e);
179
+ }
180
+ });
181
+ await shutdownActualApi();
182
+ }
183
+ });
184
+ }
185
+ finally {
186
+ isProcessingWrites = false;
187
+ // Process any new operations that were queued while we were processing
188
+ if (writeQueue.length > 0 && writeSessionTimeout === null) {
189
+ writeSessionTimeout = setTimeout(() => {
190
+ processWriteQueue();
191
+ }, WRITE_SESSION_DELAY_MS);
192
+ }
193
+ }
194
+ }
195
+ function queueWriteOperation(operation) {
196
+ return new Promise((resolve, reject) => {
197
+ writeQueue.push({ operation, resolve, reject });
198
+ // Clear existing timeout
199
+ if (writeSessionTimeout) {
200
+ clearTimeout(writeSessionTimeout);
201
+ }
202
+ // Set new timeout to process queue
203
+ writeSessionTimeout = setTimeout(() => {
204
+ processWriteQueue();
205
+ }, WRITE_SESSION_DELAY_MS);
206
+ });
207
+ }
208
+ function processQueue() {
209
+ if (running >= MAX_CONCURRENCY)
210
+ return;
211
+ const next = queue.shift();
212
+ if (!next)
213
+ return;
214
+ running++;
215
+ try {
216
+ next();
217
+ }
218
+ catch (e) {
219
+ // next() will manage its own promise resolution
220
+ running--;
221
+ processQueue();
222
+ }
223
+ }
224
+ function withConcurrency(fn) {
225
+ if (running < MAX_CONCURRENCY) {
226
+ running++;
227
+ return fn().finally(() => {
228
+ running--;
229
+ processQueue();
230
+ });
231
+ }
232
+ return new Promise((resolve, reject) => {
233
+ queue.push(async () => {
234
+ try {
235
+ const r = await fn();
236
+ resolve(r);
237
+ }
238
+ catch (err) {
239
+ reject(err);
240
+ }
241
+ finally {
242
+ running--;
243
+ processQueue();
244
+ }
245
+ });
246
+ });
247
+ }
248
+ // Expose some helpers for testing concurrency
249
+ export function getConcurrencyState() {
250
+ return { running, queueLength: queue.length, maxConcurrency: MAX_CONCURRENCY };
251
+ }
252
+ /**
253
+ * Sync local changes to the Actual Budget server.
254
+ *
255
+ * This function should be called after write operations (create, update, delete)
256
+ * to ensure changes are properly synced to the remote server. Without syncing,
257
+ * changes may only exist locally and could be lost.
258
+ *
259
+ * Note: Adds a small delay to ensure local changes are committed before syncing.
260
+ */
261
+ async function syncToServer() {
262
+ try {
263
+ // Small delay to ensure local changes are committed to the database
264
+ // before attempting to sync to the server
265
+ await new Promise(resolve => setTimeout(resolve, 100));
266
+ // TypeScript doesn't recognize sync in the type definitions, but it exists at runtime
267
+ console.log('[SYNC] Calling api.sync()...');
268
+ await api.sync();
269
+ console.log('[SYNC] api.sync() completed successfully');
270
+ }
271
+ catch (err) {
272
+ // Log but don't throw - sync failures shouldn't break the operation
273
+ console.error('[SYNC] Sync to server failed:', err);
274
+ }
275
+ }
276
+ export function setMaxConcurrency(n) {
277
+ MAX_CONCURRENCY = n;
278
+ }
279
+ /**
280
+ * Wrap a raw function with the standard adapter retry + concurrency behavior.
281
+ * Useful for tests that want to exercise retry behavior without calling the real raw methods.
282
+ */
283
+ export function callWithRetry(fn, opts) {
284
+ // retry already types the options; forward them directly and let TypeScript
285
+ // validate shapes rather than using `as any`.
286
+ return withConcurrency(() => retry(fn, opts));
287
+ }
288
+ export const notifications = new EventEmitter();
289
+ // --- Normalization helpers -------------------------------------------------
290
+ export function normalizeToTransactionArray(raw) {
291
+ if (!raw)
292
+ return [];
293
+ // If already an array of transactions
294
+ if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
295
+ return raw;
296
+ // If a single transaction object, wrap it
297
+ if (typeof raw === 'object' && raw !== null && 'id' in raw)
298
+ return [raw];
299
+ // If array of ids returned, convert to minimal Transaction objects
300
+ if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
301
+ return raw.map(id => ({ id }));
302
+ }
303
+ // Fallback: try to coerce
304
+ return Array.isArray(raw) ? raw : [];
305
+ }
306
+ export function normalizeToId(raw) {
307
+ if (typeof raw === 'string')
308
+ return raw;
309
+ if (raw && typeof raw === 'object' && 'id' in raw) {
310
+ const idVal = raw['id'];
311
+ if (typeof idVal === 'string')
312
+ return idVal;
313
+ }
314
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
315
+ return raw[0];
316
+ return String(raw ?? '');
317
+ }
318
+ export function normalizeImportResult(raw) {
319
+ if (!raw || typeof raw !== 'object')
320
+ return { added: [], updated: [], errors: [] };
321
+ const r = raw;
322
+ return {
323
+ added: Array.isArray(r.added) ? r.added : [],
324
+ updated: Array.isArray(r.updated) ? r.updated : [],
325
+ errors: Array.isArray(r.errors) ? r.errors : [],
326
+ };
327
+ }
328
+ // ---------------------------------------------------------------------------
329
+ export async function getAccounts() {
330
+ return withActualApi(async () => {
331
+ observability.incrementToolCall('actual.accounts.list').catch(() => { });
332
+ return await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
333
+ });
334
+ }
335
+ // addTransactions returns various formats: "ok", array of IDs, or Transaction objects
336
+ export async function addTransactions(txs) {
337
+ observability.incrementToolCall('actual.transactions.create').catch(() => { });
338
+ return queueWriteOperation(async () => {
339
+ // The Actual API expects addTransactions(accountId, transactions, options)
340
+ // Extract accountId from the first transaction and remove it from transaction objects
341
+ const txArray = Array.isArray(txs) ? txs : [txs];
342
+ if (txArray.length === 0) {
343
+ throw new Error('No transactions provided');
344
+ }
345
+ const accountId = txArray[0].account || txArray[0].accountId;
346
+ if (!accountId) {
347
+ throw new Error('Transaction must include account or accountId');
348
+ }
349
+ // Remove account/accountId from transaction objects as they're passed separately
350
+ const cleanedTxs = txArray.map(tx => {
351
+ const { account, accountId: _, ...rest } = tx;
352
+ return rest;
353
+ });
354
+ // API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
355
+ const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, {}), { retries: 2, backoffMs: 200 }));
356
+ // Handle various return formats
357
+ if (result === 'ok') {
358
+ // Transaction created successfully but no IDs returned
359
+ // We'll need to query the account to get the transaction IDs
360
+ return ['ok']; // Return success indicator
361
+ }
362
+ else if (Array.isArray(result)) {
363
+ // Could be array of IDs (strings) or array of Transaction objects
364
+ if (result.length === 0)
365
+ return [];
366
+ if (typeof result[0] === 'string')
367
+ return result;
368
+ if (typeof result[0] === 'object' && result[0] !== null && 'id' in result[0]) {
369
+ return result.map((t) => t.id);
370
+ }
371
+ }
372
+ else if (typeof result === 'object' && result !== null && 'id' in result) {
373
+ // Single Transaction object
374
+ return [result.id];
375
+ }
376
+ return [];
377
+ });
378
+ }
379
+ export async function importTransactions(accountId, txs) {
380
+ observability.incrementToolCall('actual.transactions.import').catch(() => { });
381
+ return queueWriteOperation(async () => {
382
+ const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200 }));
383
+ return raw || { added: [], updated: [], errors: [] };
384
+ });
385
+ }
386
+ export async function getTransactions(accountId, startDate, endDate) {
387
+ return withActualApi(async () => {
388
+ observability.incrementToolCall('actual.transactions.get').catch(() => { });
389
+ return await withConcurrency(() => retry(() => rawGetTransactions(accountId, startDate, endDate), { retries: 2, backoffMs: 200 }));
390
+ });
391
+ }
392
+ export async function getCategories() {
393
+ return withActualApi(async () => {
394
+ observability.incrementToolCall('actual.categories.get').catch(() => { });
395
+ return await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
396
+ });
397
+ }
398
+ export async function createCategory(category) {
399
+ observability.incrementToolCall('actual.categories.create').catch(() => { });
400
+ return queueWriteOperation(async () => {
401
+ try {
402
+ const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200 }));
403
+ return normalizeToId(raw);
404
+ }
405
+ catch (error) {
406
+ logger.error('[CREATE CATEGORY] Error creating category:', error);
407
+ // Re-throw the error with proper context
408
+ if (error instanceof Error) {
409
+ throw error;
410
+ }
411
+ // Handle Actual APIError plain objects: { type: "APIError", message: "..." }
412
+ const msg = error?.message ? String(error.message) : JSON.stringify(error);
413
+ throw new Error(msg);
414
+ }
415
+ });
416
+ }
417
+ export async function getPayees() {
418
+ return withActualApi(async () => {
419
+ observability.incrementToolCall('actual.payees.get').catch(() => { });
420
+ return await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
421
+ });
422
+ }
423
+ export async function createPayee(payee) {
424
+ observability.incrementToolCall('actual.payees.create').catch(() => { });
425
+ return queueWriteOperation(async () => {
426
+ const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200 }));
427
+ return normalizeToId(raw);
428
+ });
429
+ }
430
+ export async function getBudgetMonths() {
431
+ return withActualApi(async () => {
432
+ observability.incrementToolCall('actual.budgets.getMonths').catch(() => { });
433
+ return await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
434
+ });
435
+ }
436
+ export async function getBudgetMonth(month) {
437
+ return withActualApi(async () => {
438
+ observability.incrementToolCall('actual.budgets.getMonth').catch(() => { });
439
+ return await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200 }));
440
+ });
441
+ }
442
+ export async function setBudgetAmount(month, categoryId, amount) {
443
+ observability.incrementToolCall('actual.budgets.setAmount').catch(() => { });
444
+ return queueWriteOperation(async () => {
445
+ // Pre-flight: verify category exists — nil/unknown UUIDs silently no-op in Actual Budget
446
+ const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
447
+ const exists = categories.some((c) => c.id === categoryId);
448
+ if (!exists) {
449
+ throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
450
+ }
451
+ const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200 }));
452
+ return result;
453
+ });
454
+ }
455
+ export async function createAccount(account, initialBalance) {
456
+ observability.incrementToolCall('actual.accounts.create').catch(() => { });
457
+ return queueWriteOperation(async () => {
458
+ const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200 }));
459
+ const id = normalizeToId(raw);
460
+ // NO NEED for syncToServer() - shutdown() will handle persistence
461
+ return id;
462
+ });
463
+ }
464
+ export async function updateAccount(id, fields) {
465
+ observability.incrementToolCall('actual.accounts.update').catch(() => { });
466
+ return queueWriteOperation(async () => {
467
+ await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200 }));
468
+ return null;
469
+ });
470
+ }
471
+ export async function getAccountBalance(id, cutoff) {
472
+ return withActualApi(async () => {
473
+ observability.incrementToolCall('actual.accounts.get.balance').catch(() => { });
474
+ return await withConcurrency(() => retry(() => rawGetAccountBalance(id, cutoff), { retries: 2, backoffMs: 200 }));
475
+ });
476
+ }
477
+ /**
478
+ * Fetch all accounts with their current balances in a single API session.
479
+ * Using a single withActualApi session avoids N separate init/shutdown cycles
480
+ * that would occur if you called getAccountBalance() once per account.
481
+ */
482
+ export async function getAccountsWithBalances() {
483
+ return withActualApi(async () => {
484
+ observability.incrementToolCall('actual.accounts.list').catch(() => { });
485
+ const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
486
+ const result = [];
487
+ for (const account of accounts) {
488
+ try {
489
+ const balance = await rawGetAccountBalance(account.id);
490
+ result.push({ ...account, balance_current: balance });
491
+ }
492
+ catch {
493
+ result.push({ ...account, balance_current: null });
494
+ }
495
+ }
496
+ return result;
497
+ });
498
+ }
499
+ export async function deleteAccount(id) {
500
+ observability.incrementToolCall('actual.accounts.delete').catch(() => { });
501
+ return queueWriteOperation(async () => {
502
+ await withConcurrency(() => retry(() => rawDeleteAccount(id), { retries: 2, backoffMs: 200 }));
503
+ });
504
+ }
505
+ export async function updateTransaction(id, fields) {
506
+ observability.incrementToolCall('actual.transactions.update').catch(() => { });
507
+ // Use write queue to batch concurrent updates in a single budget session
508
+ return queueWriteOperation(async () => {
509
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
510
+ });
511
+ }
512
+ export async function updateTransactionBatch(updates) {
513
+ observability.incrementToolCall('actual.transactions.updateBatch').catch(() => { });
514
+ // All updates share one queueWriteOperation → one init/sync/shutdown cycle (issue #79).
515
+ // Sequential loop (not Promise.all) is intentional: concurrent rawUpdateTransaction calls
516
+ // within one session can interleave withMutation CRDT messages unpredictably.
517
+ return queueWriteOperation(async () => {
518
+ const succeeded = [];
519
+ const failed = [];
520
+ for (const { id, fields } of updates) {
521
+ try {
522
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
523
+ succeeded.push({ id });
524
+ }
525
+ catch (err) {
526
+ const message = err instanceof Error ? err.message : String(err);
527
+ failed.push({ id, error: message });
528
+ }
529
+ }
530
+ return { succeeded, failed };
531
+ });
532
+ }
533
+ export async function deleteTransaction(id) {
534
+ observability.incrementToolCall('actual.transactions.delete').catch(() => { });
535
+ return queueWriteOperation(async () => {
536
+ await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 2, backoffMs: 200 }));
537
+ });
538
+ }
539
+ export async function updateCategory(id, fields) {
540
+ observability.incrementToolCall('actual.categories.update').catch(() => { });
541
+ return queueWriteOperation(async () => {
542
+ await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200 }));
543
+ });
544
+ }
545
+ export async function deleteCategory(id) {
546
+ observability.incrementToolCall('actual.categories.delete').catch(() => { });
547
+ return queueWriteOperation(async () => {
548
+ // Pre-flight: verify category exists to avoid ECONNRESET on missing id (BUG-1)
549
+ const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
550
+ const exists = categories.some((c) => c.id === id);
551
+ if (!exists) {
552
+ throw new Error(`Category "${id}" not found. Use actual_categories_get to list available categories.`);
553
+ }
554
+ await withConcurrency(() => retry(() => rawDeleteCategory(id), { retries: 0, backoffMs: 200 }));
555
+ });
556
+ }
557
+ export async function updatePayee(id, fields) {
558
+ observability.incrementToolCall('actual.payees.update').catch(() => { });
559
+ return queueWriteOperation(async () => {
560
+ const fieldsObj = fields;
561
+ // Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
562
+ // The "default category" feature is implemented via rules (condition: payee is X → action: set category).
563
+ // Passing `category` to rawUpdatePayee would cause: "Field 'category' does not exist on table payees".
564
+ let categoryValue = undefined; // undefined = not provided
565
+ let directFields = fieldsObj;
566
+ if ('category' in fieldsObj) {
567
+ categoryValue = fieldsObj.category;
568
+ const { category: _stripped, ...rest } = fieldsObj;
569
+ directFields = rest;
570
+ }
571
+ // Update the payee's direct fields (name, transfer_acct, etc.)
572
+ if (Object.keys(directFields).length > 0) {
573
+ await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200 }));
574
+ }
575
+ // Handle category via the rules mechanism (same approach Actual Budget uses internally)
576
+ if (categoryValue !== undefined) {
577
+ const existingRules = await withConcurrency(() => retry(() => rawGetPayeeRules(id), { retries: 2, backoffMs: 200 }));
578
+ // Find an existing "set category" action rule for this payee
579
+ const setCategoryRule = existingRules.find((rule) => Array.isArray(rule.actions) &&
580
+ rule.actions.some((a) => a.op === 'set' && a.field === 'category'));
581
+ if (setCategoryRule) {
582
+ if (categoryValue === null) {
583
+ // null = clear the default category — delete the rule
584
+ await withConcurrency(() => retry(() => rawDeleteRule(setCategoryRule.id), { retries: 0, backoffMs: 200 }));
585
+ logger.debug(`[UPDATE PAYEE] Cleared default category rule for payee ${id}`);
586
+ }
587
+ else {
588
+ // Update existing rule's category action value
589
+ const updatedRule = {
590
+ ...setCategoryRule,
591
+ actions: setCategoryRule.actions.map((a) => a.op === 'set' && a.field === 'category' ? { ...a, value: categoryValue } : a),
592
+ };
593
+ await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200 }));
594
+ logger.debug(`[UPDATE PAYEE] Updated default category rule for payee ${id} to category ${categoryValue}`);
595
+ }
596
+ }
597
+ else if (categoryValue !== null) {
598
+ // Create a new "set category" rule for this payee
599
+ const newRule = {
600
+ stage: null,
601
+ conditionsOp: 'and',
602
+ conditions: [{ op: 'is', field: 'payee', value: id }],
603
+ actions: [{ op: 'set', field: 'category', value: categoryValue }],
604
+ };
605
+ await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200 }));
606
+ logger.debug(`[UPDATE PAYEE] Created default category rule for payee ${id} with category ${categoryValue}`);
607
+ }
608
+ // category=null + no existing rule = no-op (already clear)
609
+ }
610
+ });
611
+ }
612
+ export async function deletePayee(id) {
613
+ observability.incrementToolCall('actual.payees.delete').catch(() => { });
614
+ return queueWriteOperation(async () => {
615
+ // Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
616
+ const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
617
+ const exists = payees.some((p) => p.id === id);
618
+ if (!exists) {
619
+ throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
620
+ }
621
+ await withConcurrency(() => retry(() => rawDeletePayee(id), { retries: 0, backoffMs: 200 }));
622
+ });
623
+ }
624
+ export async function getRules() {
625
+ return withActualApi(async () => {
626
+ observability.incrementToolCall('actual.rules.get').catch(() => { });
627
+ const raw = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
628
+ return Array.isArray(raw) ? raw : [];
629
+ });
630
+ }
631
+ export async function createRule(rule) {
632
+ observability.incrementToolCall('actual.rules.create').catch(() => { });
633
+ return queueWriteOperation(async () => {
634
+ const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200 }));
635
+ const id = normalizeToId(raw);
636
+ return id;
637
+ });
638
+ }
639
+ export async function updateRule(id, fields) {
640
+ observability.incrementToolCall('actual.rules.update').catch(() => { });
641
+ return queueWriteOperation(async () => {
642
+ // The Actual Budget API validation requires conditions and actions arrays to exist
643
+ // We must fetch the existing rule and merge with the update fields
644
+ const rules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
645
+ const existingRule = rules.find((r) => r.id === id);
646
+ if (!existingRule) {
647
+ throw new Error(`Rule with id ${id} not found`);
648
+ }
649
+ const fieldsObj = fields;
650
+ const rule = {
651
+ id,
652
+ stage: fieldsObj.stage ?? existingRule.stage,
653
+ conditionsOp: fieldsObj.conditionsOp ?? existingRule.conditionsOp,
654
+ conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
655
+ actions: fieldsObj.actions ?? existingRule.actions ?? [],
656
+ };
657
+ logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
658
+ await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200 }));
659
+ logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
660
+ });
661
+ }
662
+ export async function deleteRule(id) {
663
+ observability.incrementToolCall('actual.rules.delete').catch(() => { });
664
+ return queueWriteOperation(async () => {
665
+ await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
666
+ });
667
+ }
668
+ export async function getSchedules() {
669
+ return withActualApi(async () => {
670
+ observability.incrementToolCall('actual.schedules.get').catch(() => { });
671
+ const raw = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
672
+ return Array.isArray(raw) ? raw : [];
673
+ });
674
+ }
675
+ export async function createSchedule(schedule) {
676
+ observability.incrementToolCall('actual.schedules.create').catch(() => { });
677
+ return queueWriteOperation(async () => {
678
+ // Note: rawCreateSchedule(schedule) passes the external schedule object directly.
679
+ // Do NOT wrap in { schedule: ... } — that would double-nest and break date parsing.
680
+ const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200 }));
681
+ const id = normalizeToId(raw);
682
+ return id;
683
+ });
684
+ }
685
+ export async function updateSchedule(id, fields, resetNextDate) {
686
+ observability.incrementToolCall('actual.schedules.update').catch(() => { });
687
+ return queueWriteOperation(async () => {
688
+ await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200 }));
689
+ });
690
+ }
691
+ export async function deleteSchedule(id) {
692
+ observability.incrementToolCall('actual.schedules.delete').catch(() => { });
693
+ return queueWriteOperation(async () => {
694
+ await withConcurrency(() => retry(() => rawDeleteSchedule(id), { retries: 0, backoffMs: 200 }));
695
+ });
696
+ }
697
+ export async function setBudgetCarryover(month, categoryId, flag) {
698
+ observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
699
+ return queueWriteOperation(async () => {
700
+ await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200 }));
701
+ });
702
+ }
703
+ export async function closeAccount(id) {
704
+ observability.incrementToolCall('actual.accounts.close').catch(() => { });
705
+ return queueWriteOperation(async () => {
706
+ await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 2, backoffMs: 200 }));
707
+ });
708
+ }
709
+ export async function reopenAccount(id) {
710
+ observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
711
+ return queueWriteOperation(async () => {
712
+ await withConcurrency(() => retry(() => rawReopenAccount(id), { retries: 2, backoffMs: 200 }));
713
+ });
714
+ }
715
+ export async function getCategoryGroups() {
716
+ return withActualApi(async () => {
717
+ observability.incrementToolCall('actual.category_groups.get').catch(() => { });
718
+ const raw = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
719
+ return Array.isArray(raw) ? raw : [];
720
+ });
721
+ }
722
+ export async function createCategoryGroup(group) {
723
+ observability.incrementToolCall('actual.category_groups.create').catch(() => { });
724
+ return queueWriteOperation(async () => {
725
+ const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200 }));
726
+ const id = normalizeToId(raw);
727
+ return id;
728
+ });
729
+ }
730
+ export async function updateCategoryGroup(id, fields) {
731
+ observability.incrementToolCall('actual.category_groups.update').catch(() => { });
732
+ return queueWriteOperation(async () => {
733
+ await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200 }));
734
+ });
735
+ }
736
+ export async function deleteCategoryGroup(id) {
737
+ observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
738
+ return queueWriteOperation(async () => {
739
+ await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 2, backoffMs: 200 }));
740
+ });
741
+ }
742
+ export async function mergePayees(targetId, mergeIds) {
743
+ observability.incrementToolCall('actual.payees.merge').catch(() => { });
744
+ return queueWriteOperation(async () => {
745
+ await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 2, backoffMs: 200 }));
746
+ });
747
+ }
748
+ export async function getPayeeRules(payeeId) {
749
+ return withActualApi(async () => {
750
+ observability.incrementToolCall('actual.payees.getPayeeRules').catch(() => { });
751
+ const allRules = await withConcurrency(() => retry(() => rawGetPayeeRules(payeeId), { retries: 2, backoffMs: 200 }));
752
+ if (!Array.isArray(allRules))
753
+ return [];
754
+ // API ignores payeeId filter — apply post-filter (BUG-3)
755
+ return allRules.filter((r) => r?.payee_id === payeeId);
756
+ });
757
+ }
758
+ export async function batchBudgetUpdates(fn) {
759
+ observability.incrementToolCall('actual.budgets.batchUpdates').catch(() => { });
760
+ return queueWriteOperation(async () => {
761
+ await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
762
+ });
763
+ }
764
+ export async function holdBudgetForNextMonth(month, amount) {
765
+ observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
766
+ return queueWriteOperation(async () => {
767
+ await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 2, backoffMs: 200 }));
768
+ });
769
+ }
770
+ export async function resetBudgetHold(month) {
771
+ observability.incrementToolCall('actual.budgets.resetHold').catch(() => { });
772
+ return queueWriteOperation(async () => {
773
+ await withConcurrency(() => retry(() => rawResetBudgetHold(month), { retries: 2, backoffMs: 200 }));
774
+ });
775
+ }
776
+ export async function runQuery(queryString) {
777
+ try {
778
+ return await withActualApi(async () => {
779
+ observability.incrementToolCall('actual.query.run').catch(() => { });
780
+ try {
781
+ // Import validation utilities
782
+ const { validateQuery, formatValidationErrors } = await import('./query-validator.js');
783
+ // The Actual Budget runQuery expects an ActualQL query object with serialize() method
784
+ // Import the q builder dynamically
785
+ const api = await import('@actual-app/api');
786
+ const q = api.q;
787
+ if (!q) {
788
+ throw new Error('ActualQL query builder not available. Ensure @actual-app/api is properly installed and the budget is loaded.');
789
+ }
790
+ // If already a serialized query object, use it directly
791
+ if (typeof queryString === 'object' && queryString !== null) {
792
+ try {
793
+ return await withConcurrency(async () => {
794
+ try {
795
+ return await rawRunQuery(queryString);
796
+ }
797
+ catch (err) {
798
+ // Catch errors from the query execution to prevent unhandled rejections
799
+ const msg = err?.message || String(err);
800
+ logger.error(`[ADAPTER] Query execution error: ${msg}`);
801
+ if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
802
+ throw new Error(`Invalid field in query: ${msg}`);
803
+ }
804
+ throw err;
805
+ }
806
+ });
807
+ }
808
+ catch (error) {
809
+ throw new Error(`Query execution failed: ${error.message}`);
810
+ }
811
+ }
812
+ const trimmed = queryString.trim();
813
+ let query;
814
+ // Check for GraphQL-like query syntax: query Name { table(...) { fields } }
815
+ const graphqlMatch = trimmed.match(/^query\s+\w+\s*\{\s*(\w+)\s*\(([^)]*)\)\s*\{([^}]+)\}\s*\}$/is);
816
+ if (graphqlMatch) {
817
+ const [, tableName, argsStr, fieldsStr] = graphqlMatch;
818
+ query = q(tableName);
819
+ // Parse arguments (e.g., startDate: "2025-06-01", endDate: "2025-11-30")
820
+ if (argsStr.trim()) {
821
+ const args = argsStr.split(',').map((a) => a.trim());
822
+ for (const arg of args) {
823
+ const argMatch = arg.match(/^(\w+):\s*"([^"]+)"$/);
824
+ if (argMatch) {
825
+ const [, key, value] = argMatch;
826
+ // Map GraphQL args to ActualQL filters
827
+ if (key === 'startDate') {
828
+ query = query.filter({ date: { $gte: value } });
829
+ }
830
+ else if (key === 'endDate') {
831
+ query = query.filter({ date: { $lte: value } });
832
+ }
833
+ else {
834
+ // Generic filter for other args
835
+ query = query.filter({ [key]: value });
836
+ }
837
+ }
838
+ }
839
+ }
840
+ // Parse fields (including nested objects like account { id name })
841
+ const fieldNames = [];
842
+ const nestedFieldPattern = /(\w+)\s*\{[^}]+\}/g;
843
+ const simpleFields = fieldsStr.replace(nestedFieldPattern, '').split(/\s+/).filter((f) => f.trim());
844
+ fieldNames.push(...simpleFields.map((f) => f.trim()));
845
+ // Extract nested field names (e.g., account, payee, category)
846
+ let nestedMatch;
847
+ while ((nestedMatch = nestedFieldPattern.exec(fieldsStr)) !== null) {
848
+ fieldNames.push(nestedMatch[1]);
849
+ }
850
+ if (fieldNames.length > 0) {
851
+ query = query.select(fieldNames);
852
+ }
853
+ }
854
+ else {
855
+ // Enhanced SQL-like parsing supporting WHERE, ORDER BY, and LIMIT
856
+ // Pattern: SELECT [fields] FROM table [WHERE conditions] [ORDER BY field ASC|DESC] [LIMIT n]
857
+ const sqlMatch = trimmed.match(/^SELECT\s+(.+?)\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?)?(?:\s+LIMIT\s+(\d+))?$/is);
858
+ if (sqlMatch) {
859
+ const [, fields, tableName, whereClause, orderField, orderDir, limitStr] = sqlMatch;
860
+ // ✅ VALIDATE QUERY BEFORE EXECUTION
861
+ const validation = validateQuery(trimmed);
862
+ if (!validation.valid) {
863
+ const errorMsg = formatValidationErrors(validation);
864
+ throw new Error(`Invalid SQL query:\n${errorMsg}\n\nQuery: "${trimmed}"`);
865
+ }
866
+ query = q(tableName);
867
+ // Apply SELECT fields (if not *)
868
+ if (fields.trim() !== '*') {
869
+ // Strip SQL aliases (AS alias_name) since ActualQL doesn't support them
870
+ const fieldList = fields.split(',').map((f) => {
871
+ const field = f.trim();
872
+ // Remove "AS alias" part if present (case-insensitive)
873
+ return field.replace(/\s+AS\s+\w+$/i, '').trim();
874
+ });
875
+ query = query.select(fieldList);
876
+ }
877
+ // Apply WHERE conditions
878
+ if (whereClause) {
879
+ query = parseWhereClause(query, whereClause);
880
+ }
881
+ // Apply ORDER BY
882
+ if (orderField) {
883
+ query = query.orderBy({ [orderField]: orderDir?.toUpperCase() === 'DESC' ? 'desc' : 'asc' });
884
+ }
885
+ // Apply LIMIT
886
+ if (limitStr) {
887
+ query = query.limit(parseInt(limitStr));
888
+ }
889
+ }
890
+ else {
891
+ // Assume it's just a table name
892
+ query = q(trimmed);
893
+ }
894
+ }
895
+ try {
896
+ return await withConcurrency(async () => {
897
+ try {
898
+ return await rawRunQuery(query);
899
+ }
900
+ catch (err) {
901
+ // Catch errors from the query execution to prevent unhandled rejections
902
+ const msg = err?.message || String(err);
903
+ logger.error(`[ADAPTER] Query execution error: ${msg}`);
904
+ if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
905
+ throw new Error(`Invalid field in query: ${msg}`);
906
+ }
907
+ throw err;
908
+ }
909
+ });
910
+ }
911
+ catch (error) {
912
+ // Enhance error messages with helpful context
913
+ const errorMsg = error?.message || String(error);
914
+ // If the error already contains formatted validation errors (with suggestions), preserve them
915
+ if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
916
+ throw error; // Re-throw the well-formatted validation error as-is
917
+ }
918
+ if (errorMsg.includes('does not exist in the schema') || errorMsg.includes('Invalid field in query') || errorMsg.includes('does not exist in table')) {
919
+ throw new Error(`Table or field does not exist. Query: "${trimmed}". Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., payee.name, category.name). Original error: ${errorMsg}`);
920
+ }
921
+ // Re-throw with original error if no specific handling
922
+ throw error;
923
+ }
924
+ }
925
+ catch (error) {
926
+ // Outer catch for query parsing errors
927
+ const errorMsg = error?.message || String(error);
928
+ if (errorMsg.includes('tableName') || errorMsg.includes('expandStar') || errorMsg.includes('Cannot read properties of undefined')) {
929
+ throw new Error(`SQL query parsing failed. The Actual Budget query engine has limitations with complex SQL features like COUNT(*), SUM(), GROUP BY, and aggregate functions. Try using simpler queries or ActualQL format instead. See https://actualbudget.org/docs/api/actual-ql/ for supported syntax. Error: ${errorMsg}`);
930
+ }
931
+ throw error;
932
+ }
933
+ });
934
+ }
935
+ catch (error) {
936
+ // Top-level catch to ensure no unhandled rejections escape
937
+ const errorMsg = error?.message || String(error);
938
+ logger.error(`[ADAPTER] Query execution failed: ${errorMsg}`);
939
+ // If the error already contains formatted validation errors with suggestions, preserve them
940
+ if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
941
+ throw error; // Re-throw the well-formatted validation error without wrapping
942
+ }
943
+ throw new Error(`Query execution failed: ${errorMsg}`);
944
+ }
945
+ }
946
+ // Helper function to parse WHERE clause conditions.
947
+ // Exported so it can be unit-tested directly.
948
+ export function parseWhereClause(query, whereClause) {
949
+ // Split by AND (simple parser - doesn't handle OR or nested conditions)
950
+ const conditions = whereClause.split(/\s+AND\s+/i);
951
+ for (const condition of conditions) {
952
+ const trimmedCondition = condition.trim();
953
+ // Handle IN clause: field IN (value1, value2, ...)
954
+ // [\w.]+ matches both simple fields (amount) and joined fields (category.name)
955
+ const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
956
+ if (inMatch) {
957
+ const [, field, valuesStr] = inMatch;
958
+ const values = valuesStr.split(',').map(v => {
959
+ const trimmed = v.trim().replace(/^['"]|['"]$/g, '');
960
+ // Try to parse as number, otherwise keep as string
961
+ const num = Number(trimmed);
962
+ return isNaN(num) ? trimmed : num;
963
+ });
964
+ query = query.filter({ [field]: { $oneof: values } });
965
+ continue;
966
+ }
967
+ // Handle comparison operators: field >= value, field <= value, field = value, etc.
968
+ // [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
969
+ const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
970
+ if (compMatch) {
971
+ const [, field, operator, valueStr] = compMatch;
972
+ const value = valueStr.trim().replace(/^['"]|['"]$/g, '');
973
+ // Map SQL operators to ActualQL operators
974
+ const operatorMap = {
975
+ '>=': '$gte',
976
+ '<=': '$lte',
977
+ '>': '$gt',
978
+ '<': '$lt',
979
+ '=': '$eq',
980
+ '!=': '$ne',
981
+ };
982
+ const actualOp = operatorMap[operator];
983
+ if (actualOp) {
984
+ // Try to parse as number if possible
985
+ const numValue = Number(value);
986
+ const finalValue = isNaN(numValue) ? value : numValue;
987
+ if (actualOp === '$eq') {
988
+ // Simple equality can use direct field: value
989
+ query = query.filter({ [field]: finalValue });
990
+ }
991
+ else {
992
+ query = query.filter({ [field]: { [actualOp]: finalValue } });
993
+ }
994
+ }
995
+ }
996
+ }
997
+ return query;
998
+ }
999
+ export async function runBankSync(accountId) {
1000
+ try {
1001
+ return await withActualApi(async () => {
1002
+ observability.incrementToolCall('actual.bank.sync').catch(() => { });
1003
+ // Bank sync must NOT be retried — retrying could import duplicate transactions.
1004
+ // Pass { accountId } for a specific account, or {} to sync all linked accounts.
1005
+ const args = accountId != null ? { accountId } : {};
1006
+ // Pre-check: verify bank-linked accounts exist before calling rawRunBankSync.
1007
+ // The SDK silently resolves void for local accounts (account_sync_source: null),
1008
+ // which would otherwise be misreported as success and cause an unnecessary 30s wait.
1009
+ if (accountId != null) {
1010
+ // Per-account check: verify the specified account is bank-linked.
1011
+ const { data: acctRows } = await rawRunQuery(api.q('accounts')
1012
+ .select(['account_sync_source', 'name'])
1013
+ .filter({ id: accountId, tombstone: false }));
1014
+ const acct = acctRows?.[0];
1015
+ if (!acct) {
1016
+ throw new Error(`Bank sync failed: Account not found (id: ${accountId})`);
1017
+ }
1018
+ if (!acct.account_sync_source) {
1019
+ throw new Error(`Bank sync failed: Account "${acct.name}" is a local account — not configured for bank sync. ` +
1020
+ `To use bank sync, link your account with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
1021
+ `See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
1022
+ }
1023
+ }
1024
+ else {
1025
+ // Global check: verify at least one bank-linked account exists across the budget.
1026
+ const { data: allAccounts } = await rawRunQuery(api.q('accounts')
1027
+ .select(['account_sync_source'])
1028
+ .filter({ tombstone: false }));
1029
+ const linkedCount = allAccounts?.filter(a => a.account_sync_source).length ?? 0;
1030
+ if (linkedCount === 0) {
1031
+ throw new Error(`Bank sync failed: No accounts are configured for bank sync. ` +
1032
+ `To use bank sync, link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
1033
+ `See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
1034
+ }
1035
+ }
1036
+ // rawRunBankSync returns void immediately; the actual provider call runs on
1037
+ // a background promise inside the SDK and surfaces errors as unhandledRejection.
1038
+ // We install a temporary listener to capture any BankSyncError and re-throw.
1039
+ let capturedRejection = null;
1040
+ const rejectionHandler = (reason) => {
1041
+ const msg = reason?.message || String(reason);
1042
+ if (reason?.type === 'BankSyncError' ||
1043
+ msg.includes('BankSyncError') ||
1044
+ msg.includes('NORDIGEN_ERROR') ||
1045
+ msg.includes('Rate limit exceeded') ||
1046
+ msg.includes('Failed syncing account') ||
1047
+ msg.includes('GoCardless') ||
1048
+ msg.includes('SimpleFIN')) {
1049
+ capturedRejection = reason;
1050
+ }
1051
+ };
1052
+ process.on('unhandledRejection', rejectionHandler);
1053
+ try {
1054
+ await rawRunBankSync(args);
1055
+ // Wait for the SDK's background promise to resolve/reject.
1056
+ // Provider errors (rate limits, auth failures) arrive in < 3s in practice;
1057
+ // BANK_SYNC_SETTLE_MS gives a comfortable margin to catch them.
1058
+ await new Promise(resolve => setTimeout(resolve, BANK_SYNC_SETTLE_MS));
1059
+ if (capturedRejection !== null)
1060
+ throw capturedRejection;
1061
+ }
1062
+ finally {
1063
+ process.off('unhandledRejection', rejectionHandler);
1064
+ }
1065
+ });
1066
+ }
1067
+ catch (error) {
1068
+ const errorMsg = error?.message || String(error);
1069
+ // Network / connectivity errors (includes "fetch failed" from Node.js native fetch)
1070
+ if (errorMsg.includes('fetch failed') ||
1071
+ errorMsg.includes('network-failure') ||
1072
+ errorMsg.includes('ECONNREFUSED') ||
1073
+ errorMsg.includes('ENOTFOUND') ||
1074
+ errorMsg.includes('Authentication failed')) {
1075
+ throw new Error(`Bank sync failed: Cannot connect to Actual Budget server. ` +
1076
+ `Check that ACTUAL_SERVER_URL is reachable from the MCP server container. (${errorMsg})`);
1077
+ }
1078
+ // Account not configured for bank sync
1079
+ if (errorMsg.includes('No bank account') ||
1080
+ errorMsg.includes('not configured') ||
1081
+ errorMsg.includes('not linked') ||
1082
+ !errorMsg ||
1083
+ errorMsg === '{}') {
1084
+ throw new Error(`Bank sync failed: The ${accountId ? 'specified account is' : 'accounts are'} not configured for bank sync. ` +
1085
+ `To use bank sync, you must first link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
1086
+ `See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
1087
+ }
1088
+ // GoCardless / SimpleFIN provider-level errors
1089
+ // BankSyncError objects (from @actual-app/api) may have { type, category, code, message }
1090
+ const bankSyncCategory = error?.category || '';
1091
+ if (errorMsg.includes('Rate limit exceeded') ||
1092
+ errorMsg.includes('RATE_LIMIT_EXCEEDED') ||
1093
+ bankSyncCategory === 'RATE_LIMIT_EXCEEDED') {
1094
+ const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
1095
+ const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
1096
+ throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
1097
+ `(NORDIGEN RATE_LIMIT_EXCEEDED — account success quota exhausted)`);
1098
+ }
1099
+ if (error?.type === 'BankSyncError' ||
1100
+ errorMsg.includes('BankSyncError') ||
1101
+ errorMsg.includes('NORDIGEN_ERROR') ||
1102
+ errorMsg.includes('Failed syncing account')) {
1103
+ throw new Error(`Bank sync failed: Provider error — ${errorMsg}`);
1104
+ }
1105
+ throw new Error(`Bank sync failed: ${errorMsg}`);
1106
+ }
1107
+ }
1108
+ export async function getBudgets() {
1109
+ return withActualApi(async () => {
1110
+ observability.incrementToolCall('actual.budgets.getAll').catch(() => { });
1111
+ const raw = await withConcurrency(() => retry(() => rawGetBudgets(), { retries: 2, backoffMs: 200 }));
1112
+ return Array.isArray(raw) ? raw : [];
1113
+ });
1114
+ }
1115
+ /**
1116
+ * Switch the active budget by name (case-insensitive, partial match supported).
1117
+ * The budget must be pre-configured via BUDGET_n_NAME env vars.
1118
+ * Switching is instant — no API call required; the next withActualApi
1119
+ * call will automatically connect to the new budget's server and sync ID.
1120
+ */
1121
+ export function switchBudget(name) {
1122
+ const key = name.toLowerCase();
1123
+ let found = budgetRegistry.get(key);
1124
+ let foundKey = key;
1125
+ if (!found) {
1126
+ // Partial / substring match
1127
+ for (const [k, v] of budgetRegistry) {
1128
+ if (k.includes(key) || key.includes(k)) {
1129
+ found = v;
1130
+ foundKey = k;
1131
+ break;
1132
+ }
1133
+ }
1134
+ }
1135
+ if (!found) {
1136
+ const available = [...budgetRegistry.values()].map(b => `"${b.name}"`).join(', ');
1137
+ throw new Error(`Budget "${name}" not found in configuration. ` +
1138
+ `Available budgets: ${available}. ` +
1139
+ `Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
1140
+ }
1141
+ activeBudgetKey = foundKey;
1142
+ logger.info(`[ADAPTER] Active budget switched to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
1143
+ return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1144
+ }
1145
+ /**
1146
+ * Return all configured budgets from the registry (for listing in actual_budgets_list_available).
1147
+ */
1148
+ export function getBudgetRegistry() {
1149
+ return [...budgetRegistry.values()].map(b => ({
1150
+ name: b.name,
1151
+ syncId: b.syncId,
1152
+ serverUrl: b.serverUrl,
1153
+ hasEncryption: !!b.encryptionPassword,
1154
+ }));
1155
+ }
1156
+ /**
1157
+ * Get the UUID for any Account, Payee, Category or Schedule by name.
1158
+ * Allowed types: 'accounts', 'schedules', 'categories', 'payees'
1159
+ */
1160
+ export async function getIDByName(type, name) {
1161
+ return withActualApi(async () => {
1162
+ observability.incrementToolCall('actual.getIDByName').catch(() => { });
1163
+ return await withConcurrency(() => retry(() => rawGetIDByName(type, name), { retries: 2, backoffMs: 200 }));
1164
+ });
1165
+ }
1166
+ /**
1167
+ * Get the current Actual Budget server version.
1168
+ * Returns { version: string } on success, { error: string } on failure.
1169
+ */
1170
+ export async function getServerVersion() {
1171
+ return withActualApi(async () => {
1172
+ observability.incrementToolCall('actual.getServerVersion').catch(() => { });
1173
+ return await withConcurrency(() => retry(() => rawGetServerVersion(), { retries: 2, backoffMs: 200 }));
1174
+ });
1175
+ }
1176
+ export default {
1177
+ getAccounts,
1178
+ getAccountsWithBalances,
1179
+ addTransactions,
1180
+ importTransactions,
1181
+ getTransactions,
1182
+ getCategories,
1183
+ createCategory,
1184
+ getPayees,
1185
+ createPayee,
1186
+ getBudgetMonths,
1187
+ getBudgetMonth,
1188
+ setBudgetAmount,
1189
+ createAccount,
1190
+ updateAccount,
1191
+ getAccountBalance,
1192
+ deleteAccount,
1193
+ updateTransaction,
1194
+ deleteTransaction,
1195
+ updateCategory,
1196
+ deleteCategory,
1197
+ updatePayee,
1198
+ deletePayee,
1199
+ getRules,
1200
+ createRule,
1201
+ updateRule,
1202
+ deleteRule,
1203
+ setBudgetCarryover,
1204
+ closeAccount,
1205
+ reopenAccount,
1206
+ getCategoryGroups,
1207
+ createCategoryGroup,
1208
+ updateCategoryGroup,
1209
+ deleteCategoryGroup,
1210
+ mergePayees,
1211
+ getPayeeRules,
1212
+ batchBudgetUpdates,
1213
+ holdBudgetForNextMonth,
1214
+ resetBudgetHold,
1215
+ runQuery,
1216
+ runBankSync,
1217
+ getBudgets,
1218
+ switchBudget,
1219
+ getBudgetRegistry,
1220
+ getIDByName,
1221
+ getServerVersion,
1222
+ getSchedules,
1223
+ createSchedule,
1224
+ updateSchedule,
1225
+ deleteSchedule,
1226
+ updateTransactionBatch,
1227
+ notifications,
1228
+ };