actual-mcp-server 0.6.27 → 0.6.29

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.
package/README.md CHANGED
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.27 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.29 | **Tool Count:** 63 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.27",
4
+ "version": "0.6.29",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
@@ -0,0 +1,80 @@
1
+ // Auth-rate-limit retry subsystem for the Actual adapter (#166 split out of
2
+ // actual-adapter.ts). Self-contained: the two observability counters are
3
+ // mutated ONLY here (withAuthRetry / the reset), so the mutable state never
4
+ // crosses a module boundary. actual-adapter reads the counts via
5
+ // getAuthRetryCounts() for getConcurrencyState, and wraps api.init() with
6
+ // withAuthRetry. Linked issue: #127.
7
+ import { DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS } from '../constants.js';
8
+ import logger from '../../logger.js';
9
+ let authRetryCount = 0; // monotonic, observability
10
+ let authRetryFailureCount = 0; // increments only when retry budget exhausted
11
+ // The auth-rate-limit path uses a deliberately LARGER backoff than the generic
12
+ // retry helper because Actual Budget's auth rate-limiter operates on a multi-
13
+ // second sliding window, not a per-request burst. The generic 200ms base
14
+ // would exhaust within 1.4s, well inside the upstream's window.
15
+ //
16
+ // Empirically (2026-05-06, #127):
17
+ // - 200ms base = 1.4s total: too short, every retry hits the throttle.
18
+ // - 2000ms base = 14s total: insufficient under heavy auth pressure.
19
+ // - 5000ms base = 5s + 10s + 10s = 25s total (each step capped by
20
+ // MAX_RETRY_DELAY_MS): clears the window in light-pressure scenarios.
21
+ //
22
+ // Beyond 25s, blocking the API mutex starts to harm tail latency for unrelated
23
+ // tool calls. The long-term fix for sustained pressure is session reuse.
24
+ const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
25
+ export function isRetryableAuthError(err) {
26
+ if (!(err instanceof Error))
27
+ return false;
28
+ return (err.message.includes('Authentication failed: too-many-requests') ||
29
+ err.message.includes('Authentication failed: network-failure'));
30
+ }
31
+ /**
32
+ * Wrap an operation with retry-on-rate-limit. Used to wrap api.init() so
33
+ * transient too-many-requests errors are absorbed transparently. The retry
34
+ * budget is capped at DEFAULT_RETRY_ATTEMPTS (3) attempts and total wallclock
35
+ * is bounded by MAX_RETRY_DELAY_MS via exponential backoff.
36
+ *
37
+ * Test-friendly: opts.maxRetries / opts.baseBackoffMs override the defaults
38
+ * so unit tests can run fast.
39
+ *
40
+ * Log hygiene: never logs the upstream URL, password, or any config-derived
41
+ * value, only the error class and the Actual error code plus the attempt counter.
42
+ */
43
+ export async function withAuthRetry(operation, opts) {
44
+ const maxRetries = opts?.maxRetries ?? DEFAULT_RETRY_ATTEMPTS;
45
+ const baseBackoffMs = opts?.baseBackoffMs ?? AUTH_RETRY_BASE_BACKOFF_MS;
46
+ let attempt = 0;
47
+ while (true) {
48
+ try {
49
+ return await operation();
50
+ }
51
+ catch (err) {
52
+ if (!isRetryableAuthError(err))
53
+ throw err;
54
+ attempt++;
55
+ if (attempt > maxRetries) {
56
+ // Budget exhausted: log + bump failure counter, but do NOT bump
57
+ // authRetryCount, which measures successful retry-and-sleep cycles.
58
+ authRetryFailureCount++;
59
+ const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
60
+ logger.error(`[ADAPTER] Auth retry exhausted after ${maxRetries} retries (last code: ${code})`);
61
+ throw err;
62
+ }
63
+ // We're going to retry: count it and sleep with exponential backoff.
64
+ authRetryCount++;
65
+ const delay = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
66
+ const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
67
+ logger.debug(`[ADAPTER] Auth retry ${attempt}/${maxRetries} (code: ${code}) after ${delay}ms`);
68
+ await new Promise(r => setTimeout(r, delay));
69
+ }
70
+ }
71
+ }
72
+ /** Read-only snapshot of the auth-retry counters for getConcurrencyState. */
73
+ export function getAuthRetryCounts() {
74
+ return { authRetries: authRetryCount, authRetryFailures: authRetryFailureCount };
75
+ }
76
+ /** Test-only: reset the auth retry observability counters. */
77
+ export function _resetAuthRetryCountersForTests() {
78
+ authRetryCount = 0;
79
+ authRetryFailureCount = 0;
80
+ }
@@ -0,0 +1,56 @@
1
+ // In-memory concurrency limiter for adapter calls (#166 split out of
2
+ // actual-adapter.ts). Self-contained: MAX_CONCURRENCY / running / queue are
3
+ // mutated ONLY by the functions here, so the mutable state never crosses a
4
+ // module boundary. It prevents bursts from overloading the Actual server.
5
+ // Intentionally tiny; replace with Bottleneck or p-queue for production.
6
+ import { DEFAULT_CONCURRENCY_LIMIT } from '../constants.js';
7
+ let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
8
+ let running = 0;
9
+ const queue = [];
10
+ function processQueue() {
11
+ if (running >= MAX_CONCURRENCY)
12
+ return;
13
+ const next = queue.shift();
14
+ if (!next)
15
+ return;
16
+ running++;
17
+ try {
18
+ next();
19
+ }
20
+ catch (e) {
21
+ // next() will manage its own promise resolution
22
+ running--;
23
+ processQueue();
24
+ }
25
+ }
26
+ export function withConcurrency(fn) {
27
+ if (running < MAX_CONCURRENCY) {
28
+ running++;
29
+ return fn().finally(() => {
30
+ running--;
31
+ processQueue();
32
+ });
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ queue.push(async () => {
36
+ try {
37
+ const r = await fn();
38
+ resolve(r);
39
+ }
40
+ catch (err) {
41
+ reject(err);
42
+ }
43
+ finally {
44
+ running--;
45
+ processQueue();
46
+ }
47
+ });
48
+ });
49
+ }
50
+ export function setMaxConcurrency(n) {
51
+ MAX_CONCURRENCY = n;
52
+ }
53
+ /** Read-only snapshot of the limiter state for getConcurrencyState. */
54
+ export function getConcurrencySnapshot() {
55
+ return { running, queueLength: queue.length, maxConcurrency: MAX_CONCURRENCY };
56
+ }
@@ -0,0 +1,43 @@
1
+ // Pure response-normalisation helpers for the Actual adapter (#166 split out of
2
+ // actual-adapter.ts). No module state, no side effects: they only coerce the
3
+ // varied raw shapes the @actual-app/api returns (id string, id array, object,
4
+ // array of objects) into the canonical shapes the tools expect. Re-exported
5
+ // from actual-adapter.ts so the public surface and all importers are unchanged.
6
+ export function normalizeToTransactionArray(raw) {
7
+ if (!raw)
8
+ return [];
9
+ // If already an array of transactions
10
+ if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
11
+ return raw;
12
+ // If a single transaction object, wrap it
13
+ if (typeof raw === 'object' && raw !== null && 'id' in raw)
14
+ return [raw];
15
+ // If array of ids returned, convert to minimal Transaction objects
16
+ if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
17
+ return raw.map(id => ({ id }));
18
+ }
19
+ // Fallback: try to coerce
20
+ return Array.isArray(raw) ? raw : [];
21
+ }
22
+ export function normalizeToId(raw) {
23
+ if (typeof raw === 'string')
24
+ return raw;
25
+ if (raw && typeof raw === 'object' && 'id' in raw) {
26
+ const idVal = raw['id'];
27
+ if (typeof idVal === 'string')
28
+ return idVal;
29
+ }
30
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
31
+ return raw[0];
32
+ return String(raw ?? '');
33
+ }
34
+ export function normalizeImportResult(raw) {
35
+ if (!raw || typeof raw !== 'object')
36
+ return { added: [], updated: [], errors: [] };
37
+ const r = raw;
38
+ return {
39
+ added: Array.isArray(r.added) ? r.added : [],
40
+ updated: Array.isArray(r.updated) ? r.updated : [],
41
+ errors: Array.isArray(r.errors) ? r.errors : [],
42
+ };
43
+ }
@@ -0,0 +1,107 @@
1
+ // SQL-to-ActualQL WHERE translation for actual_query_run (#166 split out of
2
+ // actual-adapter.ts). Pure: it only transforms a query builder via .filter()
3
+ // calls and string parsing, with no module state or side effects. parseWhereClause
4
+ // is re-exported from actual-adapter.ts and is unit-tested directly. The #178
5
+ // operator support (LIKE / NOT LIKE / IS NULL, throw-on-unsupported) lives here.
6
+ // Strip a single pair of surrounding quotes from a SQL value literal.
7
+ function _stripWhereQuotes(s) {
8
+ return s.trim().replace(/^['"]|['"]$/g, '');
9
+ }
10
+ // Coerce a SQL value literal to a number when it looks numeric, else keep the
11
+ // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
12
+ // string so an empty literal is not silently turned into 0.
13
+ function _coerceWhereValue(s) {
14
+ const v = _stripWhereQuotes(s);
15
+ if (v === '')
16
+ return v;
17
+ const n = Number(v);
18
+ return isNaN(n) ? v : n;
19
+ }
20
+ export function parseWhereClause(query, whereClause) {
21
+ // OR is not supported. Detect it up front and fail loudly. Without this guard
22
+ // a clause like `amount = 100 OR amount < 0` is left as a single fragment by
23
+ // the AND-splitter, and the comparison regex's greedy value capture swallows
24
+ // `100 OR amount < 0` into the operand, running a silently-wrong filter rather
25
+ // than erroring. That silent mishandling is exactly what #178 set out to stop.
26
+ // This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
27
+ // quoted value is a known limitation, the same as " AND ".
28
+ if (/\sOR\s/i.test(whereClause)) {
29
+ throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
30
+ `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
31
+ `Combine conditions with AND only.`);
32
+ }
33
+ // Split by AND. This is a simple parser: it does not handle OR or nested /
34
+ // parenthesised conditions (see the unsupported-operator throw below).
35
+ const conditions = whereClause.split(/\s+AND\s+/i);
36
+ for (const condition of conditions) {
37
+ const trimmedCondition = condition.trim();
38
+ if (!trimmedCondition)
39
+ continue;
40
+ // IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
41
+ // IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
42
+ const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
43
+ if (nullMatch) {
44
+ const [, field, not] = nullMatch;
45
+ query = not
46
+ ? query.filter({ [field]: { $ne: null } })
47
+ : query.filter({ [field]: null });
48
+ continue;
49
+ }
50
+ // NOT LIKE (checked before LIKE so the longer keyword wins).
51
+ const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
52
+ if (notLikeMatch) {
53
+ const [, field, valueStr] = notLikeMatch;
54
+ query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
55
+ continue;
56
+ }
57
+ // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
58
+ // so it is case-insensitive and accent-insensitive. Use % as the wildcard,
59
+ // e.g. imported_payee LIKE '%amazon%'.
60
+ const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
61
+ if (likeMatch) {
62
+ const [, field, valueStr] = likeMatch;
63
+ query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
64
+ continue;
65
+ }
66
+ // IN clause: field IN (value1, value2, ...)
67
+ // [\w.]+ matches both simple fields (amount) and joined fields (category.name)
68
+ const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
69
+ if (inMatch) {
70
+ const [, field, valuesStr] = inMatch;
71
+ const values = valuesStr.split(',').map(_coerceWhereValue);
72
+ query = query.filter({ [field]: { $oneof: values } });
73
+ continue;
74
+ }
75
+ // Comparison operators: field >= value, field = value, etc.
76
+ // [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
77
+ const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
78
+ if (compMatch) {
79
+ const [, field, operator, valueStr] = compMatch;
80
+ const operatorMap = {
81
+ '>=': '$gte',
82
+ '<=': '$lte',
83
+ '>': '$gt',
84
+ '<': '$lt',
85
+ '=': '$eq',
86
+ '!=': '$ne',
87
+ };
88
+ const actualOp = operatorMap[operator];
89
+ const finalValue = _coerceWhereValue(valueStr);
90
+ if (actualOp === '$eq') {
91
+ // Simple equality can use the direct field: value shorthand.
92
+ query = query.filter({ [field]: finalValue });
93
+ }
94
+ else {
95
+ query = query.filter({ [field]: { [actualOp]: finalValue } });
96
+ }
97
+ continue;
98
+ }
99
+ // Nothing matched. Refuse to silently drop the condition: dropping it would
100
+ // run the query UNFILTERED and hand back misleading "matches everything"
101
+ // results. Fail loudly with an actionable error instead. See #178.
102
+ throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
103
+ `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
104
+ `OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
105
+ }
106
+ return query;
107
+ }
@@ -11,7 +11,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
11
11
  } = api;
12
12
  import { EventEmitter } from 'events';
13
13
  import observability from '../observability.js';
14
- import retry from './retry.js';
14
+ import retry, { isRetryableError } from './retry.js';
15
15
  import logger from '../logger.js';
16
16
  import config from '../config.js';
17
17
  import { parseBudgetRegistry } from './budget-registry.js';
@@ -130,17 +130,13 @@ function _hasPooledConnection(sessionId) {
130
130
  * actually corrupted but the error pattern doesn't match, the next call's op
131
131
  * will surface the same root cause and we'll catch it then.
132
132
  */
133
+ // Whether an error is infrastructure-level (drop the pooled connection so the
134
+ // next call re-inits cleanly). This is the SAME class as "retryable", so it
135
+ // delegates to isRetryableError (#177): the pool-drop decision and the retry
136
+ // decision share one pattern list and cannot drift. A consistency test pins
137
+ // this equivalence.
133
138
  function _shouldDropPoolOnError(err) {
134
- if (!(err instanceof Error))
135
- return false;
136
- const msg = err.message || '';
137
- return (msg.includes('Authentication failed') ||
138
- msg.includes('ECONNRESET') ||
139
- msg.includes('ECONNREFUSED') ||
140
- msg.includes('socket hang up') ||
141
- msg.includes('ETIMEDOUT') ||
142
- msg.includes('out of memory') ||
143
- msg.includes('ENOMEM'));
139
+ return isRetryableError(err);
144
140
  }
145
141
  /**
146
142
  * Enforce per-request budget ACL before any pool branching or lock acquisition.
@@ -377,84 +373,11 @@ export function _setSkipApiInitForTests(value) {
377
373
  // The retry budget is bounded so a rate-limited init cannot indefinitely
378
374
  // hold the API mutex (withApiLock) and starve other operations.
379
375
  // ----------------------------------------------------------------------------
380
- let authRetryCount = 0; // monotonic, observability
381
- let authRetryFailureCount = 0; // increments only when retry budget exhausted
382
- // The auth-rate-limit path uses a deliberately LARGER backoff than the generic
383
- // retry helper because Actual Budget's auth rate-limiter operates on a multi-
384
- // second sliding window, not a per-request burst. The generic 200ms base
385
- // would exhaust within 1.4s — well inside the upstream's window.
386
- //
387
- // Empirically (2026-05-06, #127):
388
- // - 200ms base = 1.4s total: too short, every retry hits the throttle.
389
- // - 2000ms base = 14s total: insufficient under heavy auth pressure
390
- // (e.g. 10 rapid logins before a tool call still throttle 14s+).
391
- // - 5000ms base = 5s + 10s + 10s = 25s total (each step capped by
392
- // MAX_RETRY_DELAY_MS): clears the rate-limit window in light-pressure
393
- // scenarios (3 rapid logins) without holding the API mutex unreasonably
394
- // long.
395
- //
396
- // Beyond 25s, blocking the API mutex starts to harm tail latency for
397
- // unrelated tool calls. The proper long-term fix for sustained-pressure
398
- // scenarios is session reuse (avoid init+shutdown per op) — out of scope for
399
- // this ticket; tracked as a follow-up.
400
- const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
401
- export function isRetryableAuthError(err) {
402
- if (!(err instanceof Error))
403
- return false;
404
- return (err.message.includes('Authentication failed: too-many-requests') ||
405
- err.message.includes('Authentication failed: network-failure'));
406
- }
407
- /**
408
- * Wrap an operation with retry-on-rate-limit. Used to wrap api.init() so
409
- * transient too-many-requests errors are absorbed transparently. The retry
410
- * budget is capped at DEFAULT_RETRY_ATTEMPTS (3) attempts and total wallclock
411
- * is bounded by MAX_RETRY_DELAY_MS via exponential backoff.
412
- *
413
- * Test-friendly: opts.maxRetries / opts.baseBackoffMs override the defaults
414
- * so unit tests can run fast.
415
- *
416
- * Log hygiene: this function never logs the upstream URL, password, or any
417
- * config-derived value — only the error class and the Actual error code
418
- * (extracted from the message) plus the attempt counter.
419
- */
420
- export async function withAuthRetry(operation, opts) {
421
- const maxRetries = opts?.maxRetries ?? DEFAULT_RETRY_ATTEMPTS;
422
- const baseBackoffMs = opts?.baseBackoffMs ?? AUTH_RETRY_BASE_BACKOFF_MS;
423
- let attempt = 0;
424
- while (true) {
425
- try {
426
- return await operation();
427
- }
428
- catch (err) {
429
- if (!isRetryableAuthError(err))
430
- throw err;
431
- attempt++;
432
- if (attempt > maxRetries) {
433
- // Budget exhausted: log + bump failure counter, but do NOT bump
434
- // authRetryCount — that counter measures successful retry-and-sleep
435
- // cycles, not failed final attempts.
436
- authRetryFailureCount++;
437
- const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
438
- logger.error(`[ADAPTER] Auth retry exhausted after ${maxRetries} retries (last code: ${code})`);
439
- throw err;
440
- }
441
- // We're going to retry — count it and sleep with exponential backoff.
442
- authRetryCount++;
443
- const delay = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
444
- const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
445
- logger.debug(`[ADAPTER] Auth retry ${attempt}/${maxRetries} (code: ${code}) after ${delay}ms`);
446
- await new Promise(r => setTimeout(r, delay));
447
- }
448
- }
449
- }
450
- /**
451
- * Test-only: reset the auth retry observability counters. NOT exported via
452
- * the package public surface — only used by unit tests.
453
- */
454
- export function _resetAuthRetryCountersForTests() {
455
- authRetryCount = 0;
456
- authRetryFailureCount = 0;
457
- }
376
+ // Auth-rate-limit retry subsystem extracted to ./actual-adapter/auth-retry.ts
377
+ // (#166). Imported for internal use (wrapping api.init, getConcurrencyState)
378
+ // and re-exported so the public surface and importers are unchanged.
379
+ import { isRetryableAuthError, withAuthRetry, _resetAuthRetryCountersForTests, getAuthRetryCounts, } from './actual-adapter/auth-retry.js';
380
+ export { isRetryableAuthError, withAuthRetry, _resetAuthRetryCountersForTests };
458
381
  /**
459
382
  * Initialize Actual API - based on s-stefanov/actual-mcp pattern
460
383
  * This calls api.init() and api.downloadBudget() for each operation
@@ -550,15 +473,12 @@ async function shutdownActualApi() {
550
473
  setApiInitialized(false);
551
474
  }
552
475
  }
553
- import { BANK_SYNC_SETTLE_MS, DEFAULT_CONCURRENCY_LIMIT, DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
554
- /**
555
- * Very small concurrency limiter for adapter calls. This prevents bursts from
556
- * overloading the actual server. It's intentionally tiny and in-memory; replace
557
- * with Bottleneck or p-queue for production.
558
- */
559
- let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
560
- let running = 0;
561
- const queue = [];
476
+ import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
477
+ // Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
478
+ // Imported for internal use (every method wraps its raw call in withConcurrency)
479
+ // and setMaxConcurrency is re-exported.
480
+ import { withConcurrency, setMaxConcurrency, getConcurrencySnapshot } from './actual-adapter/concurrency.js';
481
+ export { setMaxConcurrency };
562
482
  let writeQueue = [];
563
483
  let isProcessingWrites = false;
564
484
  let writeSessionTimeout = null;
@@ -728,59 +648,16 @@ function queueWriteOperation(operation) {
728
648
  export async function withWriteSession(fn) {
729
649
  return queueWriteOperation(fn);
730
650
  }
731
- function processQueue() {
732
- if (running >= MAX_CONCURRENCY)
733
- return;
734
- const next = queue.shift();
735
- if (!next)
736
- return;
737
- running++;
738
- try {
739
- next();
740
- }
741
- catch (e) {
742
- // next() will manage its own promise resolution
743
- running--;
744
- processQueue();
745
- }
746
- }
747
- function withConcurrency(fn) {
748
- if (running < MAX_CONCURRENCY) {
749
- running++;
750
- return fn().finally(() => {
751
- running--;
752
- processQueue();
753
- });
754
- }
755
- return new Promise((resolve, reject) => {
756
- queue.push(async () => {
757
- try {
758
- const r = await fn();
759
- resolve(r);
760
- }
761
- catch (err) {
762
- reject(err);
763
- }
764
- finally {
765
- running--;
766
- processQueue();
767
- }
768
- });
769
- });
770
- }
771
651
  // Expose some helpers for testing concurrency
772
652
  export function getConcurrencyState() {
773
653
  return {
774
- running,
775
- queueLength: queue.length,
776
- maxConcurrency: MAX_CONCURRENCY,
654
+ ...getConcurrencySnapshot(),
777
655
  // Auth-retry observability — issue #127. authRetries is monotonic over the
778
656
  // process lifetime; authRetryFailures only increments when retry budget
779
657
  // exhausted. A jump in authRetries without a matching jump in
780
658
  // authRetryFailures means the retry-with-backoff is absorbing rate-limit
781
659
  // pressure (healthy). Both jumping = upstream genuinely overloaded.
782
- authRetries: authRetryCount,
783
- authRetryFailures: authRetryFailureCount,
660
+ ...getAuthRetryCounts(),
784
661
  // Pool-cooperation observability — issue #134. connectionReuses increments
785
662
  // every time withActualApi reused an existing per-session pool connection
786
663
  // instead of running its own init+shutdown cycle. Pre-#134 this was
@@ -818,9 +695,6 @@ async function syncToServer() {
818
695
  console.error('[SYNC] Sync to server failed:', err);
819
696
  }
820
697
  }
821
- export function setMaxConcurrency(n) {
822
- MAX_CONCURRENCY = n;
823
- }
824
698
  /**
825
699
  * Wrap a raw function with the standard adapter retry + concurrency behavior.
826
700
  * Useful for tests that want to exercise retry behavior without calling the real raw methods.
@@ -832,44 +706,10 @@ export function callWithRetry(fn, opts) {
832
706
  }
833
707
  export const notifications = new EventEmitter();
834
708
  // --- Normalization helpers -------------------------------------------------
835
- export function normalizeToTransactionArray(raw) {
836
- if (!raw)
837
- return [];
838
- // If already an array of transactions
839
- if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
840
- return raw;
841
- // If a single transaction object, wrap it
842
- if (typeof raw === 'object' && raw !== null && 'id' in raw)
843
- return [raw];
844
- // If array of ids returned, convert to minimal Transaction objects
845
- if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
846
- return raw.map(id => ({ id }));
847
- }
848
- // Fallback: try to coerce
849
- return Array.isArray(raw) ? raw : [];
850
- }
851
- export function normalizeToId(raw) {
852
- if (typeof raw === 'string')
853
- return raw;
854
- if (raw && typeof raw === 'object' && 'id' in raw) {
855
- const idVal = raw['id'];
856
- if (typeof idVal === 'string')
857
- return idVal;
858
- }
859
- if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
860
- return raw[0];
861
- return String(raw ?? '');
862
- }
863
- export function normalizeImportResult(raw) {
864
- if (!raw || typeof raw !== 'object')
865
- return { added: [], updated: [], errors: [] };
866
- const r = raw;
867
- return {
868
- added: Array.isArray(r.added) ? r.added : [],
869
- updated: Array.isArray(r.updated) ? r.updated : [],
870
- errors: Array.isArray(r.errors) ? r.errors : [],
871
- };
872
- }
709
+ // Extracted to ./actual-adapter/normalize.ts (#166). Imported for internal use
710
+ // and re-exported so the public surface and external importers are unchanged.
711
+ import { normalizeToTransactionArray, normalizeToId, normalizeImportResult } from './actual-adapter/normalize.js';
712
+ export { normalizeToTransactionArray, normalizeToId, normalizeImportResult };
873
713
  // ---------------------------------------------------------------------------
874
714
  export async function getAccounts() {
875
715
  return withActualApi(async () => {
@@ -897,7 +737,7 @@ export async function addTransactions(txs, options = {}) {
897
737
  return rest;
898
738
  });
899
739
  // API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
900
- const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200 }));
740
+ const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
901
741
  // Handle various return formats
902
742
  if (result === 'ok') {
903
743
  // Transaction created successfully but no IDs returned
@@ -924,7 +764,7 @@ export async function addTransactions(txs, options = {}) {
924
764
  export async function importTransactions(accountId, txs) {
925
765
  observability.incrementToolCall('actual.transactions.import').catch(() => { });
926
766
  return queueWriteOperation(async () => {
927
- const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200 }));
767
+ const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
928
768
  return raw || { added: [], updated: [], errors: [] };
929
769
  });
930
770
  }
@@ -957,7 +797,7 @@ export async function createTransfer(params) {
957
797
  payee: transferPayee.id,
958
798
  ...(params.notes !== undefined && { notes: params.notes }),
959
799
  };
960
- await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200 }));
800
+ await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
961
801
  return { success: true };
962
802
  });
963
803
  if (!writeResult.success)
@@ -998,7 +838,7 @@ export async function createCategory(category) {
998
838
  observability.incrementToolCall('actual.categories.create').catch(() => { });
999
839
  return queueWriteOperation(async () => {
1000
840
  try {
1001
- const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200 }));
841
+ const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1002
842
  return normalizeToId(raw);
1003
843
  }
1004
844
  catch (error) {
@@ -1022,7 +862,7 @@ export async function getPayees() {
1022
862
  export async function createPayee(payee) {
1023
863
  observability.incrementToolCall('actual.payees.create').catch(() => { });
1024
864
  return queueWriteOperation(async () => {
1025
- const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200 }));
865
+ const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1026
866
  return normalizeToId(raw);
1027
867
  });
1028
868
  }
@@ -1047,7 +887,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
1047
887
  if (!exists) {
1048
888
  throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
1049
889
  }
1050
- const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200 }));
890
+ const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1051
891
  return result;
1052
892
  });
1053
893
  }
@@ -1091,7 +931,7 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1091
931
  export async function createAccount(account, initialBalance) {
1092
932
  observability.incrementToolCall('actual.accounts.create').catch(() => { });
1093
933
  return queueWriteOperation(async () => {
1094
- const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200 }));
934
+ const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1095
935
  const id = normalizeToId(raw);
1096
936
  // NO NEED for syncToServer() - shutdown() will handle persistence
1097
937
  return id;
@@ -1100,7 +940,7 @@ export async function createAccount(account, initialBalance) {
1100
940
  export async function updateAccount(id, fields) {
1101
941
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1102
942
  return queueWriteOperation(async () => {
1103
- await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200 }));
943
+ await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1104
944
  return null;
1105
945
  });
1106
946
  }
@@ -1143,7 +983,7 @@ export async function updateTransaction(id, fields) {
1143
983
  observability.incrementToolCall('actual.transactions.update').catch(() => { });
1144
984
  // Use write queue to batch concurrent updates in a single budget session
1145
985
  return queueWriteOperation(async () => {
1146
- await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
986
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1147
987
  });
1148
988
  }
1149
989
  export async function updateTransactionBatch(updates) {
@@ -1156,7 +996,7 @@ export async function updateTransactionBatch(updates) {
1156
996
  const failed = [];
1157
997
  for (const { id, fields } of updates) {
1158
998
  try {
1159
- await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
999
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1160
1000
  succeeded.push({ id });
1161
1001
  }
1162
1002
  catch (err) {
@@ -1179,7 +1019,7 @@ export async function deleteTransaction(id) {
1179
1019
  export async function updateCategory(id, fields) {
1180
1020
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1181
1021
  return queueWriteOperation(async () => {
1182
- await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200 }));
1022
+ await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1183
1023
  });
1184
1024
  }
1185
1025
  export async function deleteCategory(id) {
@@ -1210,7 +1050,7 @@ export async function updatePayee(id, fields) {
1210
1050
  }
1211
1051
  // Update the payee's direct fields (name, transfer_acct, etc.)
1212
1052
  if (Object.keys(directFields).length > 0) {
1213
- await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200 }));
1053
+ await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1214
1054
  }
1215
1055
  // Handle category via the rules mechanism (same approach Actual Budget uses internally)
1216
1056
  if (categoryValue !== undefined) {
@@ -1230,7 +1070,7 @@ export async function updatePayee(id, fields) {
1230
1070
  ...setCategoryRule,
1231
1071
  actions: setCategoryRule.actions.map((a) => a.op === 'set' && a.field === 'category' ? { ...a, value: categoryValue } : a),
1232
1072
  };
1233
- await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200 }));
1073
+ await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1234
1074
  logger.debug(`[UPDATE PAYEE] Updated default category rule for payee ${id} to category ${categoryValue}`);
1235
1075
  }
1236
1076
  }
@@ -1242,7 +1082,7 @@ export async function updatePayee(id, fields) {
1242
1082
  conditions: [{ op: 'is', field: 'payee', value: id }],
1243
1083
  actions: [{ op: 'set', field: 'category', value: categoryValue }],
1244
1084
  };
1245
- await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200 }));
1085
+ await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1246
1086
  logger.debug(`[UPDATE PAYEE] Created default category rule for payee ${id} with category ${categoryValue}`);
1247
1087
  }
1248
1088
  // category=null + no existing rule = no-op (already clear)
@@ -1271,7 +1111,7 @@ export async function getRules() {
1271
1111
  export async function createRule(rule) {
1272
1112
  observability.incrementToolCall('actual.rules.create').catch(() => { });
1273
1113
  return queueWriteOperation(async () => {
1274
- const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200 }));
1114
+ const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1275
1115
  const id = normalizeToId(raw);
1276
1116
  return id;
1277
1117
  });
@@ -1295,7 +1135,7 @@ export async function updateRule(id, fields) {
1295
1135
  actions: fieldsObj.actions ?? existingRule.actions ?? [],
1296
1136
  };
1297
1137
  logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
1298
- await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200 }));
1138
+ await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1299
1139
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
1300
1140
  });
1301
1141
  }
@@ -1317,7 +1157,7 @@ export async function createSchedule(schedule) {
1317
1157
  return queueWriteOperation(async () => {
1318
1158
  // Note: rawCreateSchedule(schedule) passes the external schedule object directly.
1319
1159
  // Do NOT wrap in { schedule: ... } — that would double-nest and break date parsing.
1320
- const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200 }));
1160
+ const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1321
1161
  const id = normalizeToId(raw);
1322
1162
  return id;
1323
1163
  });
@@ -1325,7 +1165,7 @@ export async function createSchedule(schedule) {
1325
1165
  export async function updateSchedule(id, fields, resetNextDate) {
1326
1166
  observability.incrementToolCall('actual.schedules.update').catch(() => { });
1327
1167
  return queueWriteOperation(async () => {
1328
- await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200 }));
1168
+ await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1329
1169
  });
1330
1170
  }
1331
1171
  export async function deleteSchedule(id) {
@@ -1337,7 +1177,7 @@ export async function deleteSchedule(id) {
1337
1177
  export async function setBudgetCarryover(month, categoryId, flag) {
1338
1178
  observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
1339
1179
  return queueWriteOperation(async () => {
1340
- await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200 }));
1180
+ await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1341
1181
  });
1342
1182
  }
1343
1183
  export async function closeAccount(id) {
@@ -1363,7 +1203,7 @@ export async function getCategoryGroups() {
1363
1203
  export async function createCategoryGroup(group) {
1364
1204
  observability.incrementToolCall('actual.category_groups.create').catch(() => { });
1365
1205
  return queueWriteOperation(async () => {
1366
- const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200 }));
1206
+ const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1367
1207
  const id = normalizeToId(raw);
1368
1208
  return id;
1369
1209
  });
@@ -1371,7 +1211,7 @@ export async function createCategoryGroup(group) {
1371
1211
  export async function updateCategoryGroup(id, fields) {
1372
1212
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1373
1213
  return queueWriteOperation(async () => {
1374
- await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200 }));
1214
+ await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1375
1215
  });
1376
1216
  }
1377
1217
  export async function deleteCategoryGroup(id) {
@@ -1587,110 +1427,10 @@ export async function runQuery(queryString) {
1587
1427
  throw new Error(`Query execution failed: ${errorMsg}`);
1588
1428
  }
1589
1429
  }
1590
- // Helper function to parse WHERE clause conditions.
1591
- // Exported so it can be unit-tested directly.
1592
- // Strip a single pair of surrounding quotes from a SQL value literal.
1593
- function _stripWhereQuotes(s) {
1594
- return s.trim().replace(/^['"]|['"]$/g, '');
1595
- }
1596
- // Coerce a SQL value literal to a number when it looks numeric, else keep the
1597
- // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
1598
- // string so an empty literal is not silently turned into 0.
1599
- function _coerceWhereValue(s) {
1600
- const v = _stripWhereQuotes(s);
1601
- if (v === '')
1602
- return v;
1603
- const n = Number(v);
1604
- return isNaN(n) ? v : n;
1605
- }
1606
- export function parseWhereClause(query, whereClause) {
1607
- // OR is not supported. Detect it up front and fail loudly. Without this guard
1608
- // a clause like `amount = 100 OR amount < 0` is left as a single fragment by
1609
- // the AND-splitter, and the comparison regex's greedy value capture swallows
1610
- // `100 OR amount < 0` into the operand, running a silently-wrong filter rather
1611
- // than erroring. That silent mishandling is exactly what #178 set out to stop.
1612
- // This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
1613
- // quoted value is a known limitation, the same as " AND ".
1614
- if (/\sOR\s/i.test(whereClause)) {
1615
- throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
1616
- `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
1617
- `Combine conditions with AND only.`);
1618
- }
1619
- // Split by AND. This is a simple parser: it does not handle OR or nested /
1620
- // parenthesised conditions (see the unsupported-operator throw below).
1621
- const conditions = whereClause.split(/\s+AND\s+/i);
1622
- for (const condition of conditions) {
1623
- const trimmedCondition = condition.trim();
1624
- if (!trimmedCondition)
1625
- continue;
1626
- // IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
1627
- // IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
1628
- const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
1629
- if (nullMatch) {
1630
- const [, field, not] = nullMatch;
1631
- query = not
1632
- ? query.filter({ [field]: { $ne: null } })
1633
- : query.filter({ [field]: null });
1634
- continue;
1635
- }
1636
- // NOT LIKE (checked before LIKE so the longer keyword wins).
1637
- const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
1638
- if (notLikeMatch) {
1639
- const [, field, valueStr] = notLikeMatch;
1640
- query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
1641
- continue;
1642
- }
1643
- // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
1644
- // so it is case-insensitive and accent-insensitive. Use % as the wildcard,
1645
- // e.g. imported_payee LIKE '%amazon%'.
1646
- const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
1647
- if (likeMatch) {
1648
- const [, field, valueStr] = likeMatch;
1649
- query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
1650
- continue;
1651
- }
1652
- // IN clause: field IN (value1, value2, ...)
1653
- // [\w.]+ matches both simple fields (amount) and joined fields (category.name)
1654
- const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
1655
- if (inMatch) {
1656
- const [, field, valuesStr] = inMatch;
1657
- const values = valuesStr.split(',').map(_coerceWhereValue);
1658
- query = query.filter({ [field]: { $oneof: values } });
1659
- continue;
1660
- }
1661
- // Comparison operators: field >= value, field = value, etc.
1662
- // [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
1663
- const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
1664
- if (compMatch) {
1665
- const [, field, operator, valueStr] = compMatch;
1666
- const operatorMap = {
1667
- '>=': '$gte',
1668
- '<=': '$lte',
1669
- '>': '$gt',
1670
- '<': '$lt',
1671
- '=': '$eq',
1672
- '!=': '$ne',
1673
- };
1674
- const actualOp = operatorMap[operator];
1675
- const finalValue = _coerceWhereValue(valueStr);
1676
- if (actualOp === '$eq') {
1677
- // Simple equality can use the direct field: value shorthand.
1678
- query = query.filter({ [field]: finalValue });
1679
- }
1680
- else {
1681
- query = query.filter({ [field]: { [actualOp]: finalValue } });
1682
- }
1683
- continue;
1684
- }
1685
- // Nothing matched. Refuse to silently drop the condition: dropping it would
1686
- // run the query UNFILTERED and hand back misleading "matches everything"
1687
- // results. Fail loudly with an actionable error instead. See #178.
1688
- throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
1689
- `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
1690
- `OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
1691
- }
1692
- return query;
1693
- }
1430
+ // WHERE-clause translation extracted to ./actual-adapter/query.ts (#166).
1431
+ // Imported for internal use by runQuery and re-exported (unit-tested directly).
1432
+ import { parseWhereClause } from './actual-adapter/query.js';
1433
+ export { parseWhereClause };
1694
1434
  export async function runBankSync(accountId) {
1695
1435
  try {
1696
1436
  return await withActualApi(async () => {
@@ -1,9 +1,41 @@
1
1
  import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
2
2
  import { ModuleLoggers } from './loggerFactory.js';
3
3
  const log = ModuleLoggers.RETRY;
4
+ /**
5
+ * Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
6
+ * the kind a retry can actually recover from, and the kind worth dropping a
7
+ * pooled connection over. Single source of truth for #177: the adapter's
8
+ * `_shouldDropPoolOnError` delegates to `isRetryableError`, so the retry
9
+ * decision and the pool-drop decision cannot drift apart.
10
+ *
11
+ * Anything NOT matching here (domain/validation errors such as "is required",
12
+ * "not found", "does not exist", Zod failures, and any unknown error) is
13
+ * terminal: it fails the same way on every attempt, so it must NOT be retried.
14
+ */
15
+ export const TRANSIENT_ERROR_PATTERNS = [
16
+ 'Authentication failed',
17
+ 'ECONNRESET',
18
+ 'ECONNREFUSED',
19
+ 'socket hang up',
20
+ 'ETIMEDOUT',
21
+ 'out of memory',
22
+ 'ENOMEM',
23
+ ];
24
+ /**
25
+ * True only for known transient/infrastructure errors (#177). Non-Error and
26
+ * unknown rejections return false (fail fast), so a deterministic domain error
27
+ * is never retried.
28
+ */
29
+ export function isRetryableError(err) {
30
+ if (!(err instanceof Error))
31
+ return false;
32
+ const msg = err.message || '';
33
+ return TRANSIENT_ERROR_PATTERNS.some(p => msg.includes(p));
34
+ }
4
35
  export async function retry(fn, opts) {
5
36
  const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
6
37
  const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
38
+ const isRetryable = opts?.isRetryable;
7
39
  let attempt = 0;
8
40
  while (true) {
9
41
  try {
@@ -12,6 +44,14 @@ export async function retry(fn, opts) {
12
44
  return result;
13
45
  }
14
46
  catch (err) {
47
+ // Fail fast on a non-retryable (domain/validation) error when a classifier
48
+ // is supplied: retrying cannot help and only wastes work plus log noise
49
+ // (#177). With no classifier, behaviour is unchanged (retry until the
50
+ // attempt budget is exhausted), preserving every existing call site.
51
+ if (isRetryable && !isRetryable(err)) {
52
+ log.debug('Not retrying non-transient error', { error: err?.message });
53
+ throw err;
54
+ }
15
55
  attempt++;
16
56
  if (attempt > retries) {
17
57
  log.error(`All retry attempts exhausted after ${retries} tries`, err);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.27",
4
+ "version": "0.6.29",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",